diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6fed9975..e0e42858 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -28,6 +28,7 @@ ### 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1616306..d3874d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,82 @@ jobs: with: symfony-versions: '["7.3"]' + # ----------------------------------------------------------------------- + # COVERAGE — generates a Clover coverage report (PCOV) for SonarCloud to + # ingest. Kept separate from the reusable sylius_phpunit matrix (which + # runs with coverage: none) so coverage generation stays this repo's + # own concern, same as payplug/unified-plugin-core's ci.yml. + # ----------------------------------------------------------------------- + coverage: + name: Coverage + if: github.base_ref == 'develop' + runs-on: ubuntu-latest + env: + APP_ENV: test + services: + mariadb: + image: 'mariadb:10.4.11' + ports: + - '3306:3306' + env: + MYSQL_ALLOW_EMPTY_PASSWORD: true + options: '--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3' + steps: + - + uses: actions/checkout@v4 + - + name: 'Setup PHP 8.2' + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + ini-values: date.timezone=UTC + extensions: intl + tools: symfony + coverage: pcov + - + name: 'Setup Node 20.x' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - + name: 'Composer - Get Cache Directory' + id: composer-cache + run: 'echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + - + name: 'Composer - Set cache' + uses: actions/cache@v4 + with: + path: '${{ steps.composer-cache.outputs.dir }}' + key: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-${{ hashFiles(''**/composer.json'') }}' + restore-keys: 'php-8.2-sylius-2.1.0-symfony-7.3-coverage-composer-' + - + name: 'Composer - Github Auth' + run: 'composer config -g github-oauth.github.com ${{ github.token }}' + - + name: 'Install Sylius-Standard and Plugin' + run: 'make install -e SYLIUS_VERSION=2.1.0 SYMFONY_VERSION=7.3' + id: end-of-setup-sylius + - + name: 'Run tests with coverage' + run: 'composer test-coverage' + if: 'always() && steps.end-of-setup-sylius.outcome == ''success''' + - + name: 'Upload coverage report' + uses: actions/upload-artifact@v4 + with: + name: clover-coverage + path: build/logs/clover.xml + retention-days: 1 + sonarcloud: if: always() && !failure() && !cancelled() && github.base_ref == 'develop' - needs: [sylius-matrix] - uses: payplug/template-ci/.github/workflows/sonarcloud.yml@main + needs: [sylius-matrix, coverage] + uses: payplug/template-ci/.github/workflows/sonarcloud-coverage.yml@main with: project-name: 'github-payplug-payplug-syliuspayplugplugin' src-folder: 'src/' + coverage-report-artifact: 'clover-coverage' + enforce-quality-gate: true secrets: sonar-orga: ${{ secrets.SONAR_ORGA }} sonar-token: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index f47650bd..2900961e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ CLAUDE.md .claude .review .phpunit.result.cache +docs/ +build/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be32a5b..3cf7bb87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PHP 8.2+ compatibility - Use Payment Request API from Sylius - New Unified Authentication System (OAuth2) +- **Multi-shop support**: several gateway configurations of the same type may now coexist, each + connected to its own PayPlug account and scoped to its own channels +- The connected PayPlug account is displayed on each gateway's update screen +- "Disconnect this account" per gateway, clearing that gateway's credentials without touching others +- Channels already claimed by another enabled gateway of the same type are rendered unselectable +- **Hosted Fields deferred capture**: with deferred capture enabled, Hosted Fields payments are + created as authorizations and captured (in full, partially, several times) or cancelled (in full + or partially) from the admin order screen, with the remaining amount and capture deadline shown +- The Hosted Fields JS SDK URL is configurable through `PAYPLUG_HOSTED_FIELDS_SDK_URL` and defaults + to the production SDK (`v2.2.0`): QA, staging and local setups must set it to their own SDK URL > [!IMPORTANT] > Merchants will need to contact support to switch to the new authentication method. @@ -19,12 +29,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Plugin structure has been changed to follow the new Symfony bundle structure - Front assets have been migrated to use Stimulus +- Gateway uniqueness is now enforced **per channel** instead of per installation: two gateways of + the same type may both be enabled as long as their channel sets are disjoint +- Credentials are resolved from the payment method rather than from the gateway factory name, so a + request for one channel can no longer be signed with another channel's account ### Removed - Drop Payum support - Drop Sylius 1.x support - Drop usage of Secret key - Use OAuth2 instead +### Fixed +- Integrated Payment no longer accepts a payment method id that does not belong to the order's + channel, or a disabled one — previously a shopper could have the payment created on another + merchant's PayPlug account +- Oney instalment options, the Oney availability check and Apple Pay now resolve the gateway serving + the current channel instead of an arbitrary one +- Hosted Fields payments, with a new or a saved card, are accepted again by the Unified API, which + now reads the card token from `paymentMethod.hfToken` and the saved card from + `paymentMethod.storedId` +- Cards the shopper asked to save are stored again, including after a 3DS challenge (the alias is + read from `paymentMethod.storedId`) +- Refunding a Hosted Fields payment that PayPlug created without a payment reference is refused + with a clear back-office message, in addition to Sylius's own generic error message, and nothing + is sent to PayPlug; the payment itself is processed normally +- Hosted Fields deferred capture and cancel keep working with `payplug/unified-plugin-core` 1.2.1: + the plugin now passes the payment currency to UPC's `AmountHelper`, which requires it since + PRE-3724 + +### Breaking changes for anyone extending the plugin + +| Removed / changed | Replacement | +| --- | --- | +| `PayPlugApiClientFactoryInterface::create(string $factoryName)` | `createForPaymentMethod(PaymentMethodInterface $pm)` | +| `UnifiedApiPaymentCreatorInterface::createPayment($dto)` | `createPayment($dto, PaymentMethodInterface $method)` | +| `OperationStatusFetcherInterface::getOperation($id)` | `getOperation($id, PaymentMethodInterface $method)` | +| `AbstractGatewayConfigurationType::__construct()` — `$gatewayConfigRepository` and `$requestStack` dropped | translator only | +| `shouldValidateBaseCurrency()` / `baseCurrencyViolationMessage()` — `protected` → `public`, now take the **mapped** config | same hooks, new visibility/shape | +| `$gatewayFactoryName` property on the 8 configuration types | no longer read; the factory name comes off the gateway config | +| Translation key `form.only_one_gateway_allowed` | `form.gateway_channel_conflict` (`%channel%`, `%payment_method%`) | +| Injecting `PayplugUnifiedCore\Contracts\IConfigurationRepository` (its service alias is gone) | `ScopedConfigurationRepositoryInterface`, scoped per payment method | +| `PaymentMethodRepositoryInterface::findOneByGatewayName()` — **deprecated**, returns an arbitrary config when several share a factory name | `findOneEnabledByGatewayNameAndChannel($factoryName, $channel)` | +| `OneyExtension::__construct()` — `$gatewayConfigRepository` dropped, `$paymentMethodRepository` is now the plugin's `PaymentMethodRepositoryInterface` | inject the plugin repository | +| `OneySupportedPaymentChoiceProvider::__construct()` | now also takes a `ChannelContextInterface` | +| `PayPlugExtension::__construct()` | now also takes `string $hostedFieldsSdkUrl` (bound to `%payplug.hosted_fields_sdk_url%`) | + +Requires `payplug/unified-plugin-core ^1.2.1` (for the nullable `TokenOutput::$idToken` and the +Unified API payment contract: `paymentMethod.hfToken`, `paymentMethod.storedId`). + +> [!NOTE] +> A gateway connected before this release shows a "re-authenticate" placeholder instead of the +> account email until the merchant reconnects — the address is only available from the interactive +> OAuth `id_token`, which is minted at login. + Please refer to [github releases](https://github.com/payplug/SyliusPayPlugPlugin/releases) for historical release information. --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..00f23d65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM composer:2 AS composer + +FROM php:8.2-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libicu-dev \ + libzip-dev \ + libonig-dev \ + libxml2-dev \ + libpng-dev \ + libjpeg-dev \ + libfreetype6-dev \ + libsodium-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + intl \ + gd \ + sodium \ + pdo_mysql \ + mbstring \ + xml \ + dom \ + simplexml \ + xmlwriter \ + zip \ + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN useradd --create-home --uid 1000 appuser + +WORKDIR /app + +USER appuser diff --git a/Makefile b/Makefile index b3007cc4..baf9ac83 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,12 @@ SYLIUS_VERSION=2.1.0 SYMFONY_VERSION=6.4 PLUGIN_NAME=payplug/sylius-payplug-plugin +# Coverage runs inside Docker (PHP 8.2 + PCOV) instead of the host PHP, since the host's default +# `php`/`composer` may resolve to an unrelated version with no coverage driver installed. Assumes +# `vendor/` (and the Sylius test-application) is already installed on the host via `make install`. +IMAGE_DEV := sylius-payplug-plugin-dev +DOCKER_RUN := docker run --rm -v $(CURDIR):/app -w /app -u "$$(id -u):$$(id -g)" -e COMPOSER_HOME=/tmp/composer $(IMAGE_DEV) + ### ### DEVELOPMENT ### ¯¯¯¯¯¯¯¯¯¯¯ @@ -23,6 +29,14 @@ phpunit: ## Run PHPUnit tests ./vendor/bin/phpunit .PHONY: phpunit +build-dev: ## Build the Docker image used to run coverage + docker build -t $(IMAGE_DEV) . +.PHONY: build-dev + +coverage: build-dev ## Run PHPUnit tests with a Clover coverage report (build/logs/clover.xml), via Docker + $(DOCKER_RUN) composer test-coverage +.PHONY: coverage + ### ### OTHER ### ¯¯¯¯¯¯ @@ -33,11 +47,12 @@ install-sylius: @echo "Installing Sylius ${SYLIUS_VERSION} using TestApplication" ${COMPOSER} config extra.symfony.require "^${SYMFONY_VERSION}" ${COMPOSER} install - ${COMPOSER} require --dev sylius/test-application:"^${SYLIUS_VERSION}@alpha" -n -W # TODO: Remove alpha when stable + @# Capped below 2.3: Sylius 2.3 (PHP 8.3+, so only resolved on the PHP 8.4 cells) drops knp-gaufrette-bundle, + @# which sylius/refund-plugin's config still loads, so test-application:install fails. Lift once RefundPlugin follows. + @# sylius/sylius is capped explicitly: older test-application releases (the 2.0.0 cell) do not constrain it and let it float to 2.3. + ${COMPOSER} require --dev sylius/test-application:">=${SYLIUS_VERSION},<2.3@alpha" sylius/sylius:"<2.3" -n -W # TODO: Remove alpha when stable ${COMPOSER} test-application:install - - behat-configure: ## Configure Behat (cd ${TEST_DIRECTORY} && cp behat.yml.dist behat.yml) (cd ${TEST_DIRECTORY} && sed -i "s#vendor/sylius/sylius/src/Sylius/Behat/Resources/config/suites.yml#vendor/${PLUGIN_NAME}/tests/Behat/Resources/suites.yml#g" behat.yml) diff --git a/README.md b/README.md index 40b8025e..d71a5990 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=alert_status&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=duplicated_lines_density&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=code_smells&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=github-payplug-payplug-syliuspayplugplugin&metric=coverage&token=af29f9f3fbb3a74caff4e4a4d168bddab858f4dc)](https://sonarcloud.io/summary/new_code?id=github-payplug-payplug-syliuspayplugplugin) [![Version](https://img.shields.io/packagist/v/payplug/sylius-payplug-plugin.svg)](https://packagist.org/packages/payplug/sylius-payplug-plugin) [![Total Downloads](https://poser.pugx.org/payplug/sylius-payplug-plugin/downloads)](https://packagist.org/packages/payplug/sylius-payplug-plugin) diff --git a/UPGRADE.md b/UPGRADE.md index d2518688..311a214a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,35 @@ +# Deprecations + +## The Payum wiring — removal planned for early 2027 + +The plugin no longer uses Payum. The live payment flow is Sylius 2.x command/response providers, +declared in `config/services.yaml` and implemented in `src/Command/Handler/`. + +The Payum code left in the tree is inert: `PayPlugSyliusPayPlugExtension::load()` does not load +`config/services/gateway.xml`, which holds the only `payum.gateway_factory_builder` tags, so no +Payum gateway is ever registered for the `payplug*` factory names. The actions in `src/Action/` are +still tagged `payum.action` in the compiled container (via `#[AutoconfigureTag]` attributes on the +classes), but with no gateway to attach to they are never executed. + +The following are deprecated and **will be removed permanently in early 2027**: + +- `PayPlug\SyliusPayPlugPlugin\Action\CaptureAction` +- `PayPlug\SyliusPayPlugPlugin\Action\StatusAction` +- `PayPlug\SyliusPayPlugPlugin\Action\NotifyAction` +- `PayPlug\SyliusPayPlugPlugin\Action\ConvertPaymentAction` +- `PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait::setApi()` (the trait's `$payPlugApiClient` + property is **not** deprecated — it is still used by `Controller\OneClickAction`) +- `PayPlug\SyliusPayPlugPlugin\Gateway\AbstractGatewayFactory::populateConfig()` (the class itself + is **not** deprecated — it remains the home of `FACTORY_NAME`, `FACTORY_TITLE` and + `BASE_CURRENCY_CODE`) +- `config/services/gateway.xml` + +**If you do not import `config/services/gateway.xml` in your own application, this affects you in no +way** — none of the above runs today, and there is nothing to migrate. + +If you *do* import it to keep using Payum, that escape hatch stops working at the removal date. Move +to the command handlers in `src/Command/Handler/` before then. + # Upgrading from 1.0.0 1. Skip the faulty migration diff --git a/assets/controllers.json b/assets/controllers.json index 5582d953..9d9dfddd 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -16,6 +16,13 @@ "@payplug/sylius-payplug-plugin/shop/dist/payment/integrated.css": true } }, + "hosted-fields": { + "enabled": true, + "fetch": "lazy", + "autoimport": { + "@payplug/sylius-payplug-plugin/shop/dist/payment/hosted-fields.css": true + } + }, "oney-payment": { "enabled": true, "fetch": "lazy" diff --git a/assets/package.json b/assets/package.json index 52494447..c17e1363 100644 --- a/assets/package.json +++ b/assets/package.json @@ -34,6 +34,15 @@ "@payplug/sylius-payplug-plugin/shop/dist/payment/integrated.css": true } }, + "hosted-fields": { + "main": "shop/controllers/hosted-fields_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": true, + "autoimport": { + "@payplug/sylius-payplug-plugin/shop/dist/payment/hosted-fields.css": true + } + }, "oney-payment": { "main": "shop/controllers/oney-payment_controller.js", "webpackMode": "lazy", diff --git a/assets/shop/controllers/hosted-fields_controller.js b/assets/shop/controllers/hosted-fields_controller.js new file mode 100644 index 00000000..e7a4c512 --- /dev/null +++ b/assets/shop/controllers/hosted-fields_controller.js @@ -0,0 +1,233 @@ +import { Controller } from '@hotwired/stimulus'; + +const ALLOWED_BRANDS = ['CB', 'VISA', 'MASTERCARD']; + +// Applied inside each hosted iframe (Dalenys renders these rules into the field's own +// document). The SDK only accepts a small whitelist of CSS properties here — anything +// outside it (we tried "height": SDK logged "Css property ... is not supported" and threw, +// which aborted ALL fields, not just the one it complained about) — so stick to exactly the +// properties confirmed by PayPlug's own documented example (font-size/color/font-style). +// Background and sizing/centering of the field's content are NOT controllable this way. +const FIELD_STYLE = { + input: { + 'font-size': '14px', + color: '#2B343D', + 'background-color': 'transparent', + }, + '::placeholder': { + 'font-size': '14px', + color: '#969a9f', + }, +}; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['container', 'error', 'submitButton']; + + connect() { + if (typeof payplug_hosted_fields_params === 'undefined') { + return; + } + + this.form = this.element.closest('form'); + this.hfields = null; + + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.addEventListener('click', (event) => { + event.preventDefault(); + this.tokenizeAndSubmit(); + }); + } + + // With saved cards on offer, selecting the payment method is no longer enough to mean "show + // the card form": the customer must also have chosen "pay with another card". Nothing else + // establishes that initial state — handleShow()/handleHide() are only ever reached from the + // change actions wired onto the choice radios in + // templates/form/sylius_checkout_select_payment_row.html.twig, and no change event fires on + // page load — so without this branch a pre-selected saved card still renders the fields open + // (the --loaded class alone drives their visibility). Mirrors integrated-payment_controller. + if (payplug_hosted_fields_params.has_saved_cards) { + this.watchCardChoice(); + + return; + } + + // Stimulus connects as soon as the markup is in the DOM, even though the payment method + // container starts hidden (see shop/select_payment/choice.html.twig). Mounting the + // cross-origin Dalenys iframes into a display:none container breaks their rendering, so + // load them only once this payment method is actually selected. + const isChecked = this.getPaymentMethodSelectors({ + methodCode: payplug_hosted_fields_params.payment_method_code, + checked: true, + }); + if (isChecked.length) { + this.openFields(); + } + + this.getPaymentMethodSelectors().forEach((element) => { + element.addEventListener('change', (e) => { + if (payplug_hosted_fields_params.payment_method_code === e.currentTarget.value && e.currentTarget.checked) { + this.openFields(); + } + }); + }); + } + + // Opens the fields only while BOTH "pay with another card" and this payment method are + // selected — on load and on every subsequent change to either set of radios. Closing is left + // to handleHide(), which the choice radios already trigger and which also resets + // data-payment-inline-submit. + watchCardChoice() { + if (this.isOtherCardChosen()) { + this.openFields(); + } + + this.element + .querySelectorAll('.payment-choice__input, [id*="checkout_select_payment_payments"]') + .forEach((element) => { + element.addEventListener('change', () => { + if (this.isOtherCardChosen()) { + this.openFields(); + } + }); + }); + } + + isOtherCardChosen() { + if (true !== this.element.querySelector('#payplug_choice_card_other')?.checked) { + return false; + } + + // The payment method radio is absent when the shop offers only one method — Sylius renders + // no choice at all in that case, so a checked card choice is on its own enough to mean this + // method is the one being paid with. + const methodRadio = document.querySelector( + `[id*="checkout_select_payment_payments"][value="${payplug_hosted_fields_params.payment_method_code}"]`, + ); + + return null === methodRadio + ? null !== document.querySelector('.payplug-payment-choice__input:checked') + : methodRadio.checked; + } + + handleShow(event) { + if (this.hasContainerTarget) { + import('jquery').then(({ default: $ }) => { + $(this.containerTarget).slideDown(); + }); + this.openFields(); + this.containerTarget.dataset.paymentInlineSubmit = "true"; + this.element.dispatchEvent(new CustomEvent('payment-method-state-change', { bubbles: true })); + } + } + + handleHide(event) { + if (this.hasContainerTarget) { + import('jquery').then(({ default: $ }) => { + $(this.containerTarget).slideUp(); + }); + this.closeFields(); + this.containerTarget.dataset.paymentInlineSubmit = "false"; + this.element.dispatchEvent(new CustomEvent('payment-method-state-change', { bubbles: true })); + } + } + + getPaymentMethodSelectors({ methodCode, checked } = {}) { + const baseSelector = '[id*=checkout_select_payment_payments]'; + + if (methodCode) { + if (checked) { + return document.querySelectorAll(`${baseSelector}[value=${methodCode}]:checked`); + } + return document.querySelectorAll(`${baseSelector}[value=${methodCode}]`); + } + return document.querySelectorAll(baseSelector); + } + + openFields() { + if (this.hasContainerTarget) { + this.containerTarget.classList.add('payplugHostedFields--loaded'); + } + if (null === this.hfields) { + this.load(); + } + } + + closeFields() { + if (this.hasContainerTarget) { + this.containerTarget.classList.remove('payplugHostedFields--loaded'); + } + } + + load() { + this.hfields = window.dalenys.hostedFields({ + companyId: payplug_hosted_fields_params.companyId, + fields: { + brand: { id: "brand-container", version: 2, style: FIELD_STYLE }, + card: { id: "card-container", placeholder: "•••• •••• •••• ••••", enableAutospacing: true, style: FIELD_STYLE }, + expiry: { id: "expiry-container", placeholder: "MM/AA", style: FIELD_STYLE }, + cryptogram: { id: "cvv-container", placeholder: "CVV", style: FIELD_STYLE }, + }, + + locale: payplug_hosted_fields_params.locale, + }); + this.hfields.load(); + } + + tokenizeAndSubmit() { + if (null === this.hfields) { + // Fields were never mounted (payment method not selected yet): nothing to tokenize. + return; + } + + this.hideError(); + this.hfields.createToken((result) => { + if (result.execCode !== '0000') { + this.showError(payplug_hosted_fields_params.error.tokenization_failed); + return; + } + + const selectedBrand = (result.selectedBrand || '').toUpperCase(); + if (!ALLOWED_BRANDS.includes(selectedBrand)) { + this.showError(payplug_hosted_fields_params.error.unsupported_brand); + return; + } + + const saveCardElement = this.element.querySelector('#hostedfields_savecard'); + const saveCard = null !== saveCardElement && saveCardElement.checked; + + this.form.querySelector('#hostedfields_token').value = result.hfToken; + this.form.querySelector('#hostedfields_selected_brand').value = selectedBrand; + this.form.querySelector('#hostedfields_save_card').value = saveCard ? 'true' : 'false'; + // last4/expirationMonth/expirationYear/country field names are unverified against a real + // createToken() response (no vendored SDK docs/types exist in this repo to confirm them) — + // if wrong, these silently fall back to '' rather than error. This data is fully + // client-controlled and only ever used as a display-only fallback for a saved card's + // metadata when PayPlug's own operation-fetch is unavailable — PayplugCardPersister + // validates the format of each field (4-digit last4, 1-12 month, a plausible year, a + // 2-letter country) before trusting any of it, and discards anything that doesn't match + // rather than persisting it as-is. + this.form.querySelector('#hostedfields_last4').value = result.last4 || ''; + this.form.querySelector('#hostedfields_exp_month').value = result.expirationMonth || ''; + this.form.querySelector('#hostedfields_exp_year').value = result.expirationYear || ''; + this.form.querySelector('#hostedfields_country').value = result.country || ''; + this.form.submit(); + }); + } + + showError(message) { + if (!this.hasErrorTarget) { + return; + } + this.errorTarget.textContent = message; + this.errorTarget.classList.remove('payplugHostedFields__error--hide'); + } + + hideError() { + if (!this.hasErrorTarget) { + return; + } + this.errorTarget.textContent = ''; + this.errorTarget.classList.add('payplugHostedFields__error--hide'); + } +} diff --git a/assets/shop/dist/payment/hosted-fields.css b/assets/shop/dist/payment/hosted-fields.css new file mode 100644 index 00000000..d158fdd4 --- /dev/null +++ b/assets/shop/dist/payment/hosted-fields.css @@ -0,0 +1,301 @@ +.payplugHostedFields { + justify-self: center; + display: none +} + +.payplugHostedFields * { + font-family: Poppins, Arial, sans-serif !important +} + +.payplugHostedFields--loaded { + width: 100%; + max-width: 400px; + flex-wrap: wrap; + justify-content: space-between; + margin: 20px auto 0; + display: flex; + position: relative +} + +.payplugHostedFields__container { + width: 100%; + margin: 0 0 10px; + padding: 0; + display: flex; + position: relative +} + +.payplugHostedFields__schemes { + width: 100%; + display: flex; + justify-content: flex-end; + gap: 6px; + margin: 0 0 10px +} + +.payplugHostedFields__scheme-badge { + padding: 2px 6px; + border: 1px solid #d5d6d8; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + color: #2b343d; + background: #f9fafb +} + +.payplugHostedFields__cardWrapper { + width: 100%; + margin: 0 0 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px +} + +.payplugHostedFields__cardWrapper .payplugHostedFields__container--card { + flex: 1 1 100%; + min-width: 0; + margin: 0 +} + +.payplugHostedFields__network { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + position: relative +} + +.payplugHostedFields__networkLabel { + display: none; + align-items: center; + gap: 6px; + font-size: 12px; + color: #767a80 +} + +.payplugHostedFields__networkInfo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 1px solid #767a80; + border-radius: 50%; + font-size: 10px; + font-weight: 700; + font-style: italic; + cursor: pointer +} + +.payplugHostedFields__networkInfo:before { + content: "i" +} + +.payplugHostedFields__networkTooltip { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 10; + width: 280px; + max-width: 100%; + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 1.4; + color: #fff; + background: #000; + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity .15s +} + +.payplugHostedFields__networkInfo:hover .payplugHostedFields__networkTooltip, +.payplugHostedFields__networkInfo:focus .payplugHostedFields__networkTooltip { + visibility: visible; + opacity: 1 +} + +/* The SDK's own label is an absolutely positioned
in , placed with + pixel coordinates measured once. It stays in the DOM and only toggles inline display, so hide it + and show our in-flow label exactly while the SDK reveals its own. */ +body:has(.payplugHostedFields) #info-container { + display: none !important +} + +body:has(#info-container:not([style*="display: none"])) .payplugHostedFields__networkLabel { + display: inline-flex +} + +.payplugHostedFields__container--brand { + height: 44px; + min-height: 0; + width: 170px; + margin: 0 0 0 auto; + flex-shrink: 0; + align-items: center; + justify-content: center +} + +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + height: 42px; + cursor: text; + background: #fafafa; + border: 1.5px solid #d5d6d8; + border-radius: 8px; + line-height: 42px; + transition: border-color .2s, box-shadow .2s, background-color .2s +} + +.payplugHostedFields__container--card:focus-within, +.payplugHostedFields__container--expiry:focus-within, +.payplugHostedFields__container--cvv:focus-within { + background: #fff; + border-color: #2b343d; + box-shadow: 0 0 0 3px rgba(43, 52, 61, .1) +} + +.payplugHostedFields__container--card.is-invalid, +.payplugHostedFields__container--expiry.is-invalid, +.payplugHostedFields__container--cvv.is-invalid { + border-color: #e91932; + box-shadow: 0 0 0 3px rgba(233, 25, 50, .1) +} + +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + padding: 0 16px 0 50px +} + +.payplugHostedFields__container--card:before, +.payplugHostedFields__container--expiry:before, +.payplugHostedFields__container--cvv:before { + content: ""; + width: 24px; + height: 24px; + background: #95999e 50%/100% no-repeat; + position: absolute; + top: 20%; + left: 16px +} + +.payplugHostedFields__container--card:before { + -webkit-mask-image: url(card.0d2bd9bc.svg); + mask-image: url(card.0d2bd9bc.svg) +} + +.payplugHostedFields__container--expiry:before { + -webkit-mask-image: url(calendar.3c23bb16.svg); + mask-image: url(calendar.3c23bb16.svg) +} + +.payplugHostedFields__container--cvv:before { + -webkit-mask-image: url(lock.fe8a73cd.svg); + mask-image: url(lock.fe8a73cd.svg) +} + +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + max-width: calc(50% - 2px); + display: inline-block +} + +.payplugHostedFields__container--brand, +.payplugHostedFields__container--card, +.payplugHostedFields__container--expiry, +.payplugHostedFields__container--cvv { + overflow: hidden +} + +.payplugHostedFields__container--brand iframe, +.payplugHostedFields__container--card iframe, +.payplugHostedFields__container--expiry iframe, +.payplugHostedFields__container--cvv iframe { + width: 100%; + height: 100%; + border: none; + display: block +} + +.payplugHostedFields__container--saveCard { + height: auto; + align-items: center; + padding: 10px 0 0; + display: flex +} + +.payplugHostedFields__container--saveCard input { + display: none +} + +.payplugHostedFields__container--saveCard input:checked+label span:before { + opacity: 1 +} + +.payplugHostedFields__container--saveCard label { + cursor: pointer; + color: #918f8f; + margin: 0 !important; + font-size: 12px !important +} + +.payplugHostedFields__container--saveCard label span { + cursor: pointer; + height: 16px; + -o-transition: border .4s; + width: 16px; + border: 1px solid #d5d6d8; + border-radius: 2px; + margin: 0 10px -3px 0; + transition: border .4s; + display: inline-block; + position: relative +} + +.payplugHostedFields__container--saveCard label span:before { + content: ""; + height: 5px; + opacity: 0; + width: 10px; + border-top: none; + border-bottom: 2.5px solid #2b343d; + border-left: 2.5px solid #2b343d; + border-right: none; + border-radius: 1px; + transition: opacity .4s; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -55%)rotate(-48deg) +} + +.payplugHostedFields__container--saveCard label:hover { + color: #2b343d; + transition: all .1s +} + +.payplugHostedFields__container--saveCard label:hover span { + border-color: #2b343d; + transition: all .1s +} + +.payplugHostedFields__error { + color: #e91932; + width: 100%; + margin: -10px 0 10px; + padding-left: 4px; + font-size: 12px; + line-height: 18px +} + +.payplugHostedFields__error--hide { + display: none +} diff --git a/composer.json b/composer.json index 2d93bb4d..3ea65550 100755 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "ext-json": "*", "giggsey/libphonenumber-for-php": "^8.12", "payplug/payplug-php": "^4.0", + "payplug/unified-plugin-core": "^1.2.1", "php-http/message-factory": "^1.1", "sylius/refund-plugin": "^2.0", "sylius/sylius": "^2.0", @@ -26,6 +27,7 @@ "behat/mink-selenium2-driver": "1.7.0", "dmore/behat-chrome-extension": "1.4.0", "dmore/chrome-mink-driver": "2.9.3", + "doctrine/orm": ">=3.5 <3.7", "friends-of-behat/mink": "1.11.0", "friends-of-behat/mink-browserkit-driver": "1.6.2", "friends-of-behat/mink-debug-extension": "2.1.0", @@ -83,8 +85,9 @@ "ecs": "ecs check -c ruleset/ecs.php --ansi --clear-cache", "fix-ecs": "@ecs --fix --memory-limit=4G", "phpmd": "phpmd src ansi ruleset/.php_md.xml", - "phpstan": "phpstan analyse src -c ruleset/phpstan.neon", + "phpstan": "phpstan analyse src -c ruleset/phpstan.neon --memory-limit=4G", "phpunit": "phpunit tests/PHPUnit --colors=always", + "test-coverage": "phpunit tests/PHPUnit --colors=always --coverage-clover=build/logs/clover.xml", "tests": [ "@ecs", "@phpmd", diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..15ccf32d --- /dev/null +++ b/composer.lock @@ -0,0 +1,21943 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "39a63c300688d00971a9a43114e99ec7", + "packages": [ + { + "name": "alcohol/iso4217", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/alcohol/iso4217.git", + "reference": "9ea65a1ce6979f2b973948982cba7e3c5f0edea3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/alcohol/iso4217/zipball/9ea65a1ce6979f2b973948982cba7e3c5f0edea3", + "reference": "9ea65a1ce6979f2b973948982cba7e3c5f0edea3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.28 || ^10.5.58 || ^11.5.43 || ^12.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Alcohol\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com" + } + ], + "description": "ISO 4217 PHP Library", + "homepage": "http://alcohol.github.io/iso4217/", + "keywords": [ + "4217", + "ISO 4217", + "currencies", + "iso", + "library" + ], + "support": { + "issues": "https://github.com/alcohol/iso4217/issues", + "source": "https://github.com/alcohol/iso4217" + }, + "funding": [ + { + "url": "https://github.com/alcohol", + "type": "github" + } + ], + "time": "2026-01-02T09:46:17+00:00" + }, + { + "name": "api-platform/doctrine-common", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/doctrine-common.git", + "reference": "202b4e6626c01a593e61b89028f89348e7190dd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/202b4e6626c01a593e61b89028f89348e7190dd9", + "reference": "202b4e6626c01a593e61b89028f89348e7190dd9", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.2.6", + "api-platform/state": "^4.2.4", + "doctrine/collections": "^2.1 || ^3.0", + "doctrine/common": "^3.2.2", + "doctrine/persistence": "^3.2 || ^4.0", + "php": ">=8.2" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/mongodb-odm": "^2.10", + "doctrine/orm": "^2.17 || ^3.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "suggest": { + "api-platform/graphql": "For GraphQl mercure subscriptions.", + "api-platform/http-cache": "For HTTP cache invalidation.", + "phpstan/phpdoc-parser": "For PHP documentation support.", + "symfony/config": "For XML resource configuration.", + "symfony/mercure-bundle": "For mercure updates publisher.", + "symfony/yaml": "For YAML resource configuration." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Doctrine\\Common\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Common files used by api-platform/doctrine-orm and api-platform/doctrine-odm", + "homepage": "https://api-platform.com", + "keywords": [ + "doctrine", + "graphql", + "odm", + "orm", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.21" + }, + "time": "2026-09-12T13:19:57+00:00" + }, + { + "name": "api-platform/doctrine-orm", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/doctrine-orm.git", + "reference": "997fa78bf13ddc5cb3951eea61bd23f425e91486" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/997fa78bf13ddc5cb3951eea61bd23f425e91486", + "reference": "997fa78bf13ddc5cb3951eea61bd23f425e91486", + "shasum": "" + }, + "require": { + "api-platform/doctrine-common": "^4.2.23", + "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.16", + "api-platform/state": "^4.2.4", + "composer/semver": "^3.4", + "doctrine/orm": "^2.17 || ^3.0.1", + "php": ">=8.2" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.11 || ^3.1", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "ramsey/uuid": "^4.7", + "ramsey/uuid-doctrine": "^2.0", + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Doctrine\\Orm\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Doctrine ORM bridge", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "doctrine", + "graphql", + "orm", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.21" + }, + "time": "2026-09-09T07:14:18+00:00" + }, + { + "name": "api-platform/documentation", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/documentation.git", + "reference": "f07b444aef1f75bb07beb9f8d799213f05070e5f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/documentation/zipball/f07b444aef1f75bb07beb9f8d799213f05070e5f", + "reference": "f07b444aef1f75bb07beb9f8d799213f05070e5f", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5 || ^12.2" + }, + "type": "project", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Documentation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform documentation controller.", + "support": { + "source": "https://github.com/api-platform/documentation/tree/v4.4.0-alpha.1" + }, + "time": "2026-04-30T12:21:24+00:00" + }, + { + "name": "api-platform/http-cache", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/http-cache.git", + "reference": "8e71916de766f503dd60f1a1e886b4d704f881a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/http-cache/zipball/8e71916de766f503dd60f1a1e886b4d704f881a8", + "reference": "8e71916de766f503dd60f1a1e886b4d704f881a8", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", + "php": ">=8.2", + "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\HttpCache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/comunnity/contributors" + } + ], + "description": "API Platform HttpCache component", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "cache", + "http", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/http-cache/tree/v4.4.0-alpha.1" + }, + "time": "2026-06-09T14:20:49+00:00" + }, + { + "name": "api-platform/hydra", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/hydra.git", + "reference": "6498a597510fbd6aa46a25cdfa867d415b28ed9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/6498a597510fbd6aa46a25cdfa867d415b28ed9f", + "reference": "6498a597510fbd6aa46a25cdfa867d415b28ed9f", + "shasum": "" + }, + "require": { + "api-platform/documentation": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/jsonld": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3.12", + "api-platform/state": "^4.3", + "php": ">=8.2", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Hydra\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Hydra support", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "jsonapi", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/hydra/tree/v4.3.21" + }, + "time": "2026-08-16T15:12:48+00:00" + }, + { + "name": "api-platform/json-schema", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/json-schema.git", + "reference": "b97491371c40f9080bf0a4d24799d7779e1eb0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/b97491371c40f9080bf0a4d24799d7779e1eb0b2", + "reference": "b97491371c40f9080bf0a4d24799d7779e1eb0b2", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "php": ">=8.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/uid": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\JsonSchema\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Generate a JSON Schema from a PHP class", + "homepage": "https://api-platform.com", + "keywords": [ + "JSON Schema", + "api", + "json", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/json-schema/tree/v4.3.21" + }, + "time": "2026-09-02T08:21:34+00:00" + }, + { + "name": "api-platform/jsonld", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/jsonld.git", + "reference": "026a380c3c85c4210028da43e0cea1b64211bbf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/jsonld/zipball/026a380c3c85c4210028da43e0cea1b64211bbf5", + "reference": "026a380c3c85c4210028da43e0cea1b64211bbf5", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3.12", + "api-platform/state": "^4.3", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "files": [ + "./HydraContext.php" + ], + "psr-4": { + "ApiPlatform\\JsonLd\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API JSON-LD support", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "rest" + ], + "support": { + "source": "https://github.com/api-platform/jsonld/tree/v4.3.21" + }, + "time": "2026-06-13T05:11:46+00:00" + }, + { + "name": "api-platform/metadata", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/metadata.git", + "reference": "6493d73e04649e07cff914db3f2c178abeae3278" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/6493d73e04649e07cff914db3f2c178abeae3278", + "reference": "6493d73e04649e07cff914db3f2c178abeae3278", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "php": ">=8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/string": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "require-dev": { + "api-platform/json-schema": "^4.3", + "api-platform/openapi": "^4.3", + "api-platform/state": "^4.3", + "phpspec/prophecy-phpunit": "^2.2", + "phpstan/phpdoc-parser": "^1.29 || ^2.0", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "suggest": { + "phpstan/phpdoc-parser": "For PHP documentation support.", + "symfony/config": "For XML resource configuration.", + "symfony/yaml": "For YAML resource configuration." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Metadata\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Resource-oriented metadata attributes and factories", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/metadata/tree/v4.3.21" + }, + "time": "2026-09-29T18:57:50+00:00" + }, + { + "name": "api-platform/openapi", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/openapi.git", + "reference": "c72470132f2eb35a4f8f252e60342f0f7c487704" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/openapi/zipball/c72470132f2eb35a4f8f252e60342f0f7c487704", + "reference": "c72470132f2eb35a4f8f252e60342f0f7c487704", + "shasum": "" + }, + "require": { + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", + "php": ">=8.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/serializer": "^4.3.12", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/type-info": "^7.3 || ^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\OpenApi\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Models to build and serialize an OpenAPI specification.", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/openapi/tree/v4.3.21" + }, + "time": "2026-06-16T10:01:53+00:00" + }, + { + "name": "api-platform/serializer", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/serializer.git", + "reference": "d6984c417ca6c0ea22b31269c274564577ad71c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/d6984c417ca6c0ea22b31269c274564577ad71c6", + "reference": "d6984c417ca6c0ea22b31269c274564577ad71c6", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", + "php": ">=8.2", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/openapi": "^4.3", + "doctrine/collections": "^2.1", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "sebastian/exporter": "^6.3.2 || ^7.0.2", + "symfony/mercure-bundle": "^0.4.3|^0.5", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "suggest": { + "api-platform/doctrine-odm": "To support Doctrine MongoDB ODM state options.", + "api-platform/doctrine-orm": "To support Doctrine ORM state options." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform core Serializer", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "graphql", + "rest", + "serializer" + ], + "support": { + "source": "https://github.com/api-platform/serializer/tree/v4.3.21" + }, + "time": "2026-09-09T07:14:58+00:00" + }, + { + "name": "api-platform/state", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/state.git", + "reference": "e5b1b7fa70fa8a8de2065ab4fc960db035723c0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/state/zipball/e5b1b7fa70fa8a8de2065ab4fc960db035723c0b", + "reference": "e5b1b7fa70fa8a8de2065ab4fc960db035723c0b", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "php": ">=8.2", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^3.1", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/translation-contracts": "^3.0" + }, + "require-dev": { + "api-platform/serializer": "^4.3.12", + "api-platform/validator": "^4.3.1", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "willdurand/negotiation": "^3.1" + }, + "suggest": { + "api-platform/serializer": "To use API Platform serializer.", + "api-platform/validator": "To use API Platform validation.", + "symfony/http-foundation": "To use our HTTP providers and processor.", + "symfony/web-link": "To support adding web links to the response headers.", + "willdurand/negotiation": "To use the API Platform content negoatiation provider." + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\State\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform State component ", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "source": "https://github.com/api-platform/state/tree/v4.3.21" + }, + "time": "2026-09-12T13:42:25+00:00" + }, + { + "name": "api-platform/symfony", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/symfony.git", + "reference": "04aec51ec642c202cce0c476cf93ff0b0ce76ab9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/04aec51ec642c202cce0c476cf93ff0b0ce76ab9", + "reference": "04aec51ec642c202cce0c476cf93ff0b0ce76ab9", + "shasum": "" + }, + "require": { + "api-platform/documentation": "^4.3", + "api-platform/http-cache": "^4.3", + "api-platform/hydra": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/jsonld": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/openapi": "^4.3", + "api-platform/serializer": "^4.3.12", + "api-platform/state": "^4.3", + "api-platform/validator": "^4.3.1", + "php": ">=8.2", + "symfony/asset": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/security-core": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "willdurand/negotiation": "^3.1" + }, + "require-dev": { + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/elasticsearch": "^4.3", + "api-platform/graphql": "^4.3", + "api-platform/hal": "^4.3", + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/mercure-bundle": "^0.4.3|^0.5", + "symfony/object-mapper": "^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "webonyx/graphql-php": "^15.0" + }, + "suggest": { + "api-platform/doctrine-odm": "To support MongoDB. Only versions 4.0 and later are supported.", + "api-platform/doctrine-orm": "To support Doctrine ORM.", + "api-platform/elasticsearch": "To support Elasticsearch.", + "api-platform/graphql": "To support GraphQL.", + "api-platform/hal": "to support the HAL format", + "api-platform/json-api": "to support the JSON-API format", + "api-platform/ramsey-uuid": "To support Ramsey's UUID identifiers.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", + "psr/cache-implementation": "To use metadata caching.", + "symfony/cache": "To have metadata caching when using Symfony integration.", + "symfony/config": "To load XML configuration files.", + "symfony/expression-language": "To use authorization and mercure advanced features.", + "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/mercure-bundle": "To support mercure integration.", + "symfony/messenger": "To support messenger integration and asynchronous Mercure updates.", + "symfony/security": "To use authorization features.", + "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Symfony\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "Symfony API Platform integration", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger", + "symfony" + ], + "support": { + "source": "https://github.com/api-platform/symfony/tree/v4.3.21" + }, + "time": "2026-09-29T14:56:10+00:00" + }, + { + "name": "api-platform/validator", + "version": "v4.3.21", + "source": { + "type": "git", + "url": "https://github.com/api-platform/validator.git", + "reference": "b7a35b2a15216229ef93271b1fdd4a4d691d2ba7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/validator/zipball/b7a35b2a15216229ef93271b1fdd4a4d691d2ba7", + "reference": "b7a35b2a15216229ef93271b1fdd4a4d691d2ba7", + "shasum": "" + }, + "require": { + "api-platform/metadata": "^4.3", + "php": ">=8.2", + "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", + "symfony/serializer": "^6.4 || ^7.1 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0", + "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", + "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/api-platform/api-platform", + "name": "api-platform/api-platform" + }, + "symfony": { + "require": "^6.4 || ^7.0 || ^8.0" + }, + "branch-alias": { + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-main": "4.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Validator\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "description": "API Platform validator component", + "homepage": "https://api-platform.com", + "keywords": [ + "api", + "graphql", + "rest", + "validator" + ], + "support": { + "source": "https://github.com/api-platform/validator/tree/v4.3.21" + }, + "time": "2026-09-05T06:05:04+00:00" + }, + { + "name": "babdev/pagerfanta-bundle", + "version": "v4.6.0", + "source": { + "type": "git", + "url": "https://github.com/BabDev/PagerfantaBundle.git", + "reference": "ea3eb6a3f9d838de73254683b1a57014877d2ff3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/BabDev/PagerfantaBundle/zipball/ea3eb6a3f9d838de73254683b1a57014877d2ff3", + "reference": "ea3eb6a3f9d838de73254683b1a57014877d2ff3", + "shasum": "" + }, + "require": { + "pagerfanta/core": "^3.7 || ^4.0", + "php": "^8.1", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-foundation": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/property-access": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/routing": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "jms/serializer": "<3.18", + "jms/serializer-bundle": "<4.2", + "pagerfanta/twig": "<3.7", + "symfony/serializer": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", + "symfony/translation": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", + "symfony/twig-bridge": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", + "symfony/twig-bundle": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", + "twig/twig": "<2.13", + "white-october/pagerfanta-bundle": "*" + }, + "require-dev": { + "jms/serializer": "^3.18", + "jms/serializer-bundle": "^4.2 || ^5.0", + "matthiasnoback/symfony-dependency-injection-test": "^6.2", + "pagerfanta/twig": "^3.7 || ^4.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "2.1.32", + "phpstan/phpstan-phpunit": "2.0.8", + "phpstan/phpstan-symfony": "2.0.9", + "phpunit/phpunit": "10.5.58 || 11.5.44 || 12.4.4", + "symfony/serializer": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/translation": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "twig/twig": "^2.13 || ^3.0" + }, + "suggest": { + "jms/serializer-bundle": "To use the Pagerfanta class with the JMS Serializer", + "symfony/serializer": "To use the Pagerfanta class with the Symfony Serializer", + "symfony/translation": "To use the Twig templates with translation support", + "twig/twig": "To integrate Pagerfanta with Twig through extensions" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "BabDev\\PagerfantaBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Bundle integrating Pagerfanta with Symfony", + "keywords": [ + "pagerfanta", + "pagination", + "symfony" + ], + "support": { + "issues": "https://github.com/BabDev/PagerfantaBundle/issues", + "source": "https://github.com/BabDev/PagerfantaBundle/tree/v4.6.0" + }, + "funding": [ + { + "url": "https://github.com/mbabker", + "type": "github" + } + ], + "time": "2025-11-29T13:01:51+00:00" + }, + { + "name": "behat/transliterator", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Transliterator.git", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "chuyskywalker/rolling-curl": "^3.1", + "php-yaoi/php-yaoi": "^1.0", + "phpunit/phpunit": "^8.5.25 || ^9.5.19" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Transliterator\\": "src/Behat/Transliterator" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Artistic-1.0" + ], + "description": "String transliterator", + "keywords": [ + "i18n", + "slug", + "transliterator" + ], + "support": { + "issues": "https://github.com/Behat/Transliterator/issues", + "source": "https://github.com/Behat/Transliterator/tree/v1.5.0" + }, + "abandoned": true, + "time": "2022-03-30T09:27:43+00:00" + }, + { + "name": "brick/math", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-09-12T10:28:18+00:00" + }, + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, + { + "name": "composer/semver", + "version": "3.5.1", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993", + "reference": "528948210cc46f967a4b98e83b19e76f2b504993", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.5.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-10-05T13:12:12+00:00" + }, + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/common", + "version": "3.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/common.git", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0", + "doctrine/collections": "^1", + "phpstan/phpstan": "^1.4.1", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5.20 || ^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.0", + "symfony/phpunit-bridge": "^6.1", + "vimeo/psalm": "^4.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "PHP Doctrine Common project is a library that provides additional functionality that other Doctrine projects depend on such as better reflection support, proxies and much more.", + "homepage": "https://www.doctrine-project.org/projects/common.html", + "keywords": [ + "common", + "doctrine", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/common/issues", + "source": "https://github.com/doctrine/common/tree/3.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcommon", + "type": "tidelift" + } + ], + "time": "2025-01-01T22:12:03+00:00" + }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/bf7ac3a050b54b261cedfb3d0a44733819062275", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "doctrine/phpcr-odm": "^1.8 || ^2.0", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "jackalope/jackalope-fs": "*", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "10.5.63 || 12.5.12", + "symfony/cache": "^6.4 || ^7 || ^8", + "symfony/var-exporter": "^6.4 || ^7 || ^8" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2026-04-01T13:56:01+00:00" + }, + { + "name": "doctrine/dbal", + "version": "3.10.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "c95589d775a0b2e543467d40f8c3ecccf586f2b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/c95589d775a0b2e543467d40f8c3ecccf586f2b4", + "reference": "c95589d775a0b2e543467d40f8c3ecccf586f2b4", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" + }, + "require-dev": { + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.34", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.6" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-07-21T12:57:49+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/doctrine-bundle", + "version": "2.19.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "72b53688c30451a7c65addd50b2167d31459724a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/72b53688c30451a7c65addd50b2167d31459724a", + "reference": "72b53688c30451a7c65addd50b2167d31459724a", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", + "doctrine/persistence": "^3.1 || ^4", + "doctrine/sql-formatter": "^1.0.1", + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5 || ^3" + }, + "conflict": { + "doctrine/annotations": ">=3.0", + "doctrine/cache": "< 1.11", + "doctrine/orm": "<2.17 || >=4.0", + "symfony/var-exporter": "< 6.4.1 || 7.0.0", + "twig/twig": "<2.13 || >=3.0 <3.0.4 || >=5" + }, + "require-dev": { + "doctrine/annotations": "^1 || ^2", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.17 || ^3.1", + "friendsofphp/proxy-manager-lts": "^1.0", + "phpstan/phpstan": "2.1.1", + "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", + "psr/log": "^1.1.4 || ^2.0 || ^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/runtime": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/twig-bridge": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4.1 || ^7.0.1", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^2.14.7 || ^3.0.4 || ^4" + }, + "suggest": { + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "ext-pdo": "*", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org/" + } + ], + "description": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database", + "dbal", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2026-09-08T11:48:41+00:00" + }, + { + "name": "doctrine/doctrine-migrations-bundle", + "version": "3.7.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "00056695242a3e88369fe7a6e10669d8f6d7496a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/00056695242a3e88369fe7a6e10669d8f6d7496a", + "reference": "00056695242a3e88369fe7a6e10669d8f6d7496a", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^2 || ^3 || ^4", + "doctrine/doctrine-bundle": "^2.4 || ^3.0", + "doctrine/migrations": "^3.2", + "php": "^7.2 || ^8.0", + "psr/log": "^1 || ^2 || ^3", + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/coding-standard": "^12 || ^14", + "doctrine/orm": "^2.6 || ^3", + "phpstan/phpstan": "^1.4 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpstan/phpstan-symfony": "^1.3 || ^2", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/phpunit-bridge": "^6.3 || ^7 || ^8", + "symfony/var-exporter": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\MigrationsBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineMigrationsBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "dbal", + "migrations", + "schema" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-migrations-bundle", + "type": "tidelift" + } + ], + "time": "2026-08-26T05:40:19+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "doctrine/migrations", + "version": "3.9.8", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "eb0b9ce3f999d720fa73037bbd06977ea34b76a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/eb0b9ce3f999d720fa73037bbd06977ea34b76a0", + "reference": "eb0b9ce3f999d720fa73037bbd06977ea34b76a0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/dbal": "^3.6 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2.0", + "php": "^8.1", + "psr/log": "^1.1.3 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/orm": "<2.12 || >=4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.13 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", + "doctrine/sql-formatter": "^1.0", + "ext-pdo_sqlite": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." + }, + "bin": [ + "bin/doctrine-migrations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Migrations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" + } + ], + "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", + "keywords": [ + "database", + "dbal", + "migrations" + ], + "support": { + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.8" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", + "type": "tidelift" + } + ], + "time": "2026-10-04T12:35:58+00:00" + }, + { + "name": "doctrine/orm", + "version": "3.6.9", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "1ee072c5734f1d4e41d67eff6587f29c96db51c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/1ee072c5734f1d4e41d67eff6587f29c96db51c8", + "reference": "1ee072c5734f1d4e41d67eff6587f29c96db51c8", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.1.23", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.5.0 || ^11.5", + "psr/log": "^1 || ^2 || ^3", + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)", + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.6.9" + }, + "time": "2026-09-07T18:59:09+00:00" + }, + { + "name": "doctrine/persistence", + "version": "3.4.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "703a29d8597336fb75f42eeabcdf3f2863156ca8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/703a29d8597336fb75f42eeabcdf3f2863156ca8", + "reference": "703a29d8597336fb75f42eeabcdf3f2863156ca8", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^1 || ^2", + "php": "^7.2 || ^8.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "conflict": { + "doctrine/common": "<2.10" + }, + "require-dev": { + "doctrine/coding-standard": "^12 || ^14", + "doctrine/common": "^3.0", + "phpstan/phpstan": "^1 || 2.1.30", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8.5.38 || ^9.5", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/3.4.5" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-06-13T19:29:35+00:00" + }, + { + "name": "doctrine/sql-formatter", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" + }, + "bin": [ + "bin/sql-formatter" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\SqlFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" + }, + "time": "2026-02-08T16:21:46+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "friendsofphp/proxy-manager-lts", + "version": "v1.0.19", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfPHP/proxy-manager-lts.git", + "reference": "c20299aa9f48a622052964a75c5a4cef017398b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfPHP/proxy-manager-lts/zipball/c20299aa9f48a622052964a75c5a4cef017398b2", + "reference": "c20299aa9f48a622052964a75c5a4cef017398b2", + "shasum": "" + }, + "require": { + "laminas/laminas-code": "~3.4.1|^4.0", + "php": ">=7.1", + "symfony/filesystem": "^4.4.17|^5.0|^6.0|^7.0|^8.0" + }, + "conflict": { + "laminas/laminas-stdlib": "<3.2.1", + "zendframework/zend-stdlib": "<3.2.1" + }, + "replace": { + "ocramius/proxy-manager": "^2.1" + }, + "require-dev": { + "ext-phar": "*", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/Ocramius/ProxyManager", + "name": "ocramius/proxy-manager" + } + }, + "autoload": { + "psr-4": { + "ProxyManager\\": "src/ProxyManager" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + } + ], + "description": "Adding support for a wider range of PHP versions to ocramius/proxy-manager", + "homepage": "https://github.com/FriendsOfPHP/proxy-manager-lts", + "keywords": [ + "aop", + "lazy loading", + "proxy", + "proxy pattern", + "service proxies" + ], + "support": { + "issues": "https://github.com/FriendsOfPHP/proxy-manager-lts/issues", + "source": "https://github.com/FriendsOfPHP/proxy-manager-lts/tree/v1.0.19" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ocramius/proxy-manager", + "type": "tidelift" + } + ], + "time": "2025-10-28T10:28:17+00:00" + }, + { + "name": "gedmo/doctrine-extensions", + "version": "v3.22.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git", + "reference": "486719169b3042dd4cdb801f70651f30723aab9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/486719169b3042dd4cdb801f70651f30723aab9e", + "reference": "486719169b3042dd4cdb801f70651f30723aab9e", + "shasum": "" + }, + "require": { + "doctrine/collections": "^1.2 || ^2.0 || ^3.1", + "doctrine/deprecations": "^1.0", + "doctrine/event-manager": "^1.2 || ^2.0", + "doctrine/persistence": "^2.2 || ^3.0 || ^4.0", + "php": "^7.4 || ^8.0", + "psr/cache": "^1 || ^2 || ^3", + "psr/clock": "^1", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/string": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "behat/transliterator": "<1.2 || >=2.0", + "doctrine/annotations": "<1.13 || >=3.0", + "doctrine/common": "<2.13 || >=4.0", + "doctrine/dbal": "<3.7 || >=5.0", + "doctrine/mongodb-odm": "<2.3 || >=3.0", + "doctrine/orm": "<2.20 || >=3.0 <3.3 || >=4.0" + }, + "require-dev": { + "behat/transliterator": "^1.2", + "doctrine/annotations": "^1.13 || ^2.0", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/common": "^2.13 || ^3.0", + "doctrine/dbal": "^3.7 || ^4.0", + "doctrine/doctrine-bundle": "^2.3 || ^3.0", + "doctrine/mongodb-odm": "^2.3", + "doctrine/orm": "^2.20 || ^3.3", + "friendsofphp/php-cs-fixer": "^3.89", + "nesbot/carbon": "^2.71 || ^3.0", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-doctrine": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.3", + "phpunit/phpunit": "^9.6", + "rector/rector": "^2.2.6", + "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/doctrine-bridge": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/phpunit-bridge": "^6.4 || ^7.3 || ^8.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.3 || ^8.0" + }, + "suggest": { + "doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM", + "doctrine/orm": "to use the extensions with the ORM" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gedmo\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gediminas Morkevicius", + "email": "gediminas.morkevicius@gmail.com" + }, + { + "name": "Gustavo Falco", + "email": "comfortablynumb84@gmail.com" + }, + { + "name": "David Buchmann", + "email": "david@liip.ch" + } + ], + "description": "Doctrine behavioral extensions", + "homepage": "http://gediminasm.org/", + "keywords": [ + "Blameable", + "behaviors", + "doctrine", + "extensions", + "gedmo", + "loggable", + "nestedset", + "odm", + "orm", + "sluggable", + "sortable", + "timestampable", + "translatable", + "tree", + "uploadable" + ], + "support": { + "docs": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc", + "issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues", + "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.22.2" + }, + "funding": [ + { + "url": "https://github.com/l3pp4rd", + "type": "github" + }, + { + "url": "https://github.com/mbabker", + "type": "github" + }, + { + "url": "https://github.com/phansys", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-09-29T18:56:23+00:00" + }, + { + "name": "giggsey/libphonenumber-for-php", + "version": "8.13.45", + "source": { + "type": "git", + "url": "https://github.com/giggsey/libphonenumber-for-php.git", + "reference": "142ccdd603e4eeef7de9a7ddbd9ae18c2651dbd6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/142ccdd603e4eeef7de9a7ddbd9ae18c2651dbd6", + "reference": "142ccdd603e4eeef7de9a7ddbd9ae18c2651dbd6", + "shasum": "" + }, + "require": { + "giggsey/locale": "^1.7|^2.0", + "php": ">=5.3.2", + "symfony/polyfill-mbstring": "^1.17" + }, + "replace": { + "giggsey/libphonenumber-for-php-lite": "self.version" + }, + "require-dev": { + "pear/pear-core-minimal": "^1.9", + "pear/pear_exception": "^1.0", + "pear/versioncontrol_git": "^0.5", + "phing/phing": "^2.7", + "php-coveralls/php-coveralls": "^1.0|^2.0", + "symfony/console": "^2.8|^3.0|^v4.4|^v5.2", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "libphonenumber\\": "src/" + }, + "exclude-from-classmap": [ + "/src/data/", + "/src/carrier/data/", + "/src/geocoding/data/", + "/src/timezone/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Joshua Gigg", + "email": "giggsey@gmail.com", + "homepage": "https://giggsey.com/" + } + ], + "description": "PHP Port of Google's libphonenumber", + "homepage": "https://github.com/giggsey/libphonenumber-for-php", + "keywords": [ + "geocoding", + "geolocation", + "libphonenumber", + "mobile", + "phonenumber", + "validation" + ], + "support": { + "issues": "https://github.com/giggsey/libphonenumber-for-php/issues", + "source": "https://github.com/giggsey/libphonenumber-for-php" + }, + "time": "2024-09-06T11:22:54+00:00" + }, + { + "name": "giggsey/locale", + "version": "1.9", + "source": { + "type": "git", + "url": "https://github.com/giggsey/Locale.git", + "reference": "b07f1eace8072ccc61445ad8fbd493ff9d783043" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/giggsey/Locale/zipball/b07f1eace8072ccc61445ad8fbd493ff9d783043", + "reference": "b07f1eace8072ccc61445ad8fbd493ff9d783043", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "pear/pear-core-minimal": "^1.9", + "pear/pear_exception": "^1.0", + "pear/versioncontrol_git": "^0.5", + "phing/phing": "~2.7", + "php-coveralls/php-coveralls": "^1.0|^2.0", + "phpunit/phpunit": "^4.8|^5.0", + "symfony/console": "^2.8|^3.0|^4.0", + "symfony/filesystem": "^2.8|^3.0|^4.0", + "symfony/finder": "^2.8|^3.0|^4.0", + "symfony/process": "^2.8|^3.0|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Giggsey\\Locale\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joshua Gigg", + "email": "giggsey@gmail.com", + "homepage": "http://giggsey.com/" + } + ], + "description": "Locale functions required by libphonenumber-for-php", + "support": { + "issues": "https://github.com/giggsey/Locale/issues", + "source": "https://github.com/giggsey/Locale/tree/master" + }, + "time": "2020-07-07T11:16:24+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "imagine/imagine", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/php-imagine/Imagine.git", + "reference": "dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16", + "reference": "dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" + }, + "suggest": { + "ext-exif": "to read EXIF metadata", + "ext-gd": "to use the GD implementation", + "ext-gmagick": "to use the Gmagick implementation", + "ext-imagick": "to use the Imagick implementation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Imagine\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bulat Shakirzyanov", + "email": "mallluhuct@gmail.com", + "homepage": "http://avalanche123.com" + } + ], + "description": "Image processing for PHP", + "homepage": "http://imagine.readthedocs.org/", + "keywords": [ + "drawing", + "graphics", + "image manipulation", + "image processing" + ], + "support": { + "issues": "https://github.com/php-imagine/Imagine/issues", + "source": "https://github.com/php-imagine/Imagine/tree/1.5.4" + }, + "time": "2026-06-04T10:05:48+00:00" + }, + { + "name": "knplabs/gaufrette", + "version": "v0.11.1", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/Gaufrette.git", + "reference": "3cc396cae0f7c7d3f965e6b69d8fc546ffa0f94c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/Gaufrette/zipball/3cc396cae0f7c7d3f965e6b69d8fc546ffa0f94c", + "reference": "3cc396cae0f7c7d3f965e6b69d8fc546ffa0f94c", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "microsoft/windowsazure": "<0.4.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.9", + "mikey179/vfsstream": "v1.x-dev as 1.7.0", + "pedrotroller/php-cs-custom-fixer": "^2.28", + "phpspec/phpspec": "^7.0", + "phpunit/phpunit": "~8.0" + }, + "suggest": { + "ext-fileinfo": "This extension is used to automatically detect the content-type of a file in the AwsS3, OpenCloud, AzureBlogStorage and GoogleCloudStorage adapters", + "knplabs/knp-gaufrette-bundle": "to use with Symfony" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.10.x-dev" + } + }, + "autoload": { + "psr-0": { + "Gaufrette": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "The contributors", + "homepage": "http://github.com/knplabs/Gaufrette/contributors" + } + ], + "description": "PHP library that provides a filesystem abstraction layer", + "homepage": "http://knplabs.com", + "keywords": [ + "abstraction", + "file", + "filesystem", + "media" + ], + "support": { + "issues": "https://github.com/KnpLabs/Gaufrette/issues", + "source": "https://github.com/KnpLabs/Gaufrette/tree/v0.11.1" + }, + "time": "2022-11-03T17:26:17+00:00" + }, + { + "name": "knplabs/knp-gaufrette-bundle", + "version": "v0.9.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpGaufretteBundle.git", + "reference": "910a16cd7a7af5d72a98975f3e59a0c6feadec32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpGaufretteBundle/zipball/910a16cd7a7af5d72a98975f3e59a0c6feadec32", + "reference": "910a16cd7a7af5d72a98975f3e59a0c6feadec32", + "shasum": "" + }, + "require": { + "knplabs/gaufrette": "^0.11", + "php": "^7.4 || ^8.0", + "symfony/config": "^5.0|^6.0|^7.0", + "symfony/dependency-injection": "^5.0|^6.0|^7.0", + "symfony/http-kernel": "^5.0|^6.0|^7.0" + }, + "require-dev": { + "symfony/console": "^5.0|^6.0|^7.0", + "symfony/filesystem": "^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^7.0", + "symfony/yaml": "^5.0|^6.0|^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "0.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\GaufretteBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antoine Hérault", + "email": "antoine.herault@gmail.com" + }, + { + "name": "The contributors", + "homepage": "https://github.com/knplabs/KnpGaufretteBundle/contributors" + } + ], + "description": "Allows to easily use the Gaufrette library in a Symfony project", + "homepage": "http://knplabs.com", + "keywords": [ + "abstraction", + "file", + "filesystem", + "media" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpGaufretteBundle/issues", + "source": "https://github.com/KnpLabs/KnpGaufretteBundle/tree/v0.9.0" + }, + "time": "2023-12-18T09:53:43+00:00" + }, + { + "name": "knplabs/knp-menu", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpMenu.git", + "reference": "79d325909a1d428a93f1a0f55e90177830e283bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpMenu/zipball/79d325909a1d428a93f1a0f55e90177830e283bb", + "reference": "79d325909a1d428a93f1a0f55e90177830e283bb", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "symfony/http-foundation": "<5.4", + "twig/twig": "<2.16" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.6", + "psr/container": "^1.0 || ^2.0", + "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^7.0", + "symfony/routing": "^5.4 || ^6.0 || ^7.0", + "twig/twig": "^2.16 || ^3.0" + }, + "suggest": { + "twig/twig": "for the TwigRenderer and the integration with your templates" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Menu\\": "src/Knp/Menu" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs", + "homepage": "https://knplabs.com" + }, + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + }, + { + "name": "The Community", + "homepage": "https://github.com/KnpLabs/KnpMenu/contributors" + } + ], + "description": "An object oriented menu library", + "homepage": "https://knplabs.com", + "keywords": [ + "menu", + "tree" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpMenu/issues", + "source": "https://github.com/KnpLabs/KnpMenu/tree/v3.8.0" + }, + "time": "2025-06-13T15:03:33+00:00" + }, + { + "name": "knplabs/knp-menu-bundle", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpMenuBundle.git", + "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpMenuBundle/zipball/aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", + "reference": "aa22e57f8f41c34ad5e382aae4d0c12998c0eb5a", + "shasum": "" + }, + "require": { + "knplabs/knp-menu": "^3.8", + "php": "^8.1", + "symfony/config": "^6.4 | ^7.0 | ^8.0", + "symfony/dependency-injection": "^6.4 | ^7.0 | ^8.0", + "symfony/deprecation-contracts": "^2.5 | ^3.3", + "symfony/http-kernel": "^6.4 | ^7.0 | ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 | ^11.5 | ^12.4", + "symfony/expression-language": "^6.4 | ^7.0 | ^8.0", + "symfony/phpunit-bridge": "^7.0 | ^8.0", + "symfony/templating": "^6.4 | ^7.0 | ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\MenuBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Knplabs", + "homepage": "http://knplabs.com" + }, + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/KnpMenuBundle/contributors" + } + ], + "description": "This bundle provides an integration of the KnpMenu library", + "keywords": [ + "menu" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpMenuBundle/issues", + "source": "https://github.com/KnpLabs/KnpMenuBundle/tree/v3.7.0" + }, + "time": "2025-11-30T08:30:04+00:00" + }, + { + "name": "knplabs/knp-snappy", + "version": "v1.7.3", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/snappy.git", + "reference": "f749a7d2e0f1260f4a0f96925323e34ede898f1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/f749a7d2e0f1260f4a0f96925323e34ede898f1f", + "reference": "f749a7d2e0f1260f4a0f96925323e34ede898f1f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0||^3.0", + "symfony/process": "^5.0||^6.0||^7.0||^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "pedrotroller/php-cs-custom-fixer": "^2.19", + "phpstan/phpstan": "^2.1.39", + "phpstan/phpstan-phpunit": "^2.0.15", + "phpunit/phpunit": "^9.6.29" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Snappy\\": "src/Knp/Snappy" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KNP Labs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "http://github.com/KnpLabs/snappy/contributors" + } + ], + "description": "PHP library allowing thumbnail, snapshot or PDF generation from a url or a html page. Wrapper for wkhtmltopdf/wkhtmltoimage.", + "homepage": "http://github.com/KnpLabs/snappy", + "keywords": [ + "knp", + "knplabs", + "pdf", + "snapshot", + "thumbnail", + "wkhtmltopdf" + ], + "support": { + "issues": "https://github.com/KnpLabs/snappy/issues", + "source": "https://github.com/KnpLabs/snappy/tree/v1.7.3" + }, + "time": "2026-07-29T11:03:07+00:00" + }, + { + "name": "knplabs/knp-snappy-bundle", + "version": "v1.10.6", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpSnappyBundle.git", + "reference": "9501e76b63158bc6c390065ede40963d5a94b065" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpSnappyBundle/zipball/9501e76b63158bc6c390065ede40963d5a94b065", + "reference": "9501e76b63158bc6c390065ede40963d5a94b065", + "shasum": "" + }, + "require": { + "knplabs/knp-snappy": "^1.4.3", + "php": ">=8.1", + "symfony/framework-bundle": "^5.1|^6.0|^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "symfony/yaml": "^5.1|^6.0|^7.0|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Knp\\Bundle\\SnappyBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "http://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "http://github.com/KnpLabs/KnpSnappyBundle/contributors" + } + ], + "description": "Easily create PDF and images in Symfony by converting Twig/HTML templates.", + "homepage": "http://github.com/KnpLabs/KnpSnappyBundle", + "keywords": [ + "bundle", + "knp", + "knplabs", + "pdf", + "snappy" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpSnappyBundle/issues", + "source": "https://github.com/KnpLabs/KnpSnappyBundle/tree/v1.10.6" + }, + "time": "2026-01-07T08:05:32+00:00" + }, + { + "name": "laminas/laminas-code", + "version": "4.17.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-code.git", + "reference": "40d61e2899ec17c5d08bbc0a2d586b3ca17ab9bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-code/zipball/40d61e2899ec17c5d08bbc0a2d586b3ca17ab9bd", + "reference": "40d61e2899ec17c5d08bbc0a2d586b3ca17ab9bd", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0.1", + "ext-phar": "*", + "laminas/laminas-coding-standard": "^3.0.0", + "laminas/laminas-stdlib": "^3.18.0", + "phpunit/phpunit": "^10.5.58", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.15.0" + }, + "suggest": { + "doctrine/annotations": "Doctrine\\Common\\Annotations >=1.0 for annotation features", + "laminas/laminas-stdlib": "Laminas\\Stdlib component" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Code\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Extensions to the PHP Reflection API, static code scanning, and code generation", + "homepage": "https://laminas.dev", + "keywords": [ + "code", + "laminas", + "laminasframework" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-code/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-code/issues", + "rss": "https://github.com/laminas/laminas-code/releases.atom", + "source": "https://github.com/laminas/laminas-code" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-11-01T09:38:14+00:00" + }, + { + "name": "laminas/laminas-stdlib", + "version": "3.21.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-stdlib.git", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-stdlib": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.1.0", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Stdlib\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "SPL extensions, array utilities, error handlers, and more", + "homepage": "https://laminas.dev", + "keywords": [ + "laminas", + "stdlib" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-stdlib/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-stdlib/issues", + "rss": "https://github.com/laminas/laminas-stdlib/releases.atom", + "source": "https://github.com/laminas/laminas-stdlib" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T18:13:12+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-bundle", + "version": "3.7.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-bundle.git", + "reference": "9cccb6862c9fcd16e6d8a0fbd62254abb8017c99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-bundle/zipball/9cccb6862c9fcd16e6d8a0fbd62254abb8017c99", + "reference": "9cccb6862c9fcd16e6d8a0fbd62254abb8017c99", + "shasum": "" + }, + "require": { + "league/flysystem": "^3.0", + "php": ">=8.2", + "symfony/config": "^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.0 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/http-kernel": "^6.0 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/mongodb-odm": "^2.0", + "league/flysystem-async-aws-s3": "^3.1", + "league/flysystem-aws-s3-v3": "^3.1", + "league/flysystem-azure-blob-storage": "^3.1", + "league/flysystem-ftp": "^3.1", + "league/flysystem-google-cloud-storage": "^3.1", + "league/flysystem-gridfs": "^3.28", + "league/flysystem-memory": "^3.1", + "league/flysystem-read-only": "^3.15", + "league/flysystem-sftp-v3": "^3.1", + "league/flysystem-webdav": "^3.29", + "platformcommunity/flysystem-bunnycdn": "^3.3", + "symfony/dotenv": "^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^6.0 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^6.0 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "League\\FlysystemBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + } + ], + "description": "Symfony bundle integrating Flysystem into Symfony applications", + "keywords": [ + "Flysystem", + "bundle", + "filesystem", + "symfony" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem-bundle/issues", + "source": "https://github.com/thephpleague/flysystem-bundle/tree/3.7.1" + }, + "time": "2026-08-10T15:55:07+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-components", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-components.git", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "shasum": "" + }, + "require": { + "league/uri": "^7.8.1", + "php": "^8.1" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-mbstring": "to use the sorting algorithm of URLSearchParams", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI components manipulation library", + "homepage": "http://uri.thephpleague.com", + "keywords": [ + "authority", + "components", + "fragment", + "host", + "middleware", + "modifier", + "path", + "port", + "query", + "rfc3986", + "scheme", + "uri", + "url", + "userinfo" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/jwt": "^5.0", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.4|^3.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "api-platform/core": "^3.0|^4.0", + "rector/rector": "^1.2", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "suggest": { + "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", + "spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Lexik\\Bundle\\JWTAuthenticationBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Barthe", + "email": "j.barthe@lexik.fr", + "homepage": "https://github.com/jeremyb" + }, + { + "name": "Nicolas Cabot", + "email": "n.cabot@lexik.fr", + "homepage": "https://github.com/slashfan" + }, + { + "name": "Cedric Girard", + "email": "c.girard@lexik.fr", + "homepage": "https://github.com/cedric-g" + }, + { + "name": "Dev Lexik", + "email": "dev@lexik.fr", + "homepage": "https://github.com/lexik" + }, + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com", + "homepage": "https://github.com/chalasr" + }, + { + "name": "Lexik Community", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors" + } + ], + "description": "This bundle provides JWT authentication for your Symfony REST API", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle", + "keywords": [ + "Authentication", + "JWS", + "api", + "bundle", + "jwt", + "rest", + "symfony" + ], + "support": { + "issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues", + "source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-20T17:47:00+00:00" + }, + { + "name": "liip/imagine-bundle", + "version": "2.18.1", + "source": { + "type": "git", + "url": "https://github.com/liip/LiipImagineBundle.git", + "reference": "3c799cf7c56b87166f82c6c48c082cd95977cf6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/3c799cf7c56b87166f82c6c48c082cd95977cf6c", + "reference": "3c799cf7c56b87166f82c6c48c082cd95977cf6c", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "imagine/imagine": "^1.3.2", + "php": "^8.1", + "symfony/dependency-injection": "^5.4|^6.4|^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3", + "symfony/filesystem": "^5.4|^6.4|^7.3|^8.0", + "symfony/finder": "^5.4|^6.4|^7.3|^8.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.3|^8.0", + "symfony/mime": "^5.4|^6.4|^7.3|^8.0", + "symfony/options-resolver": "^5.4|^6.4|^7.3|^8.0", + "symfony/process": "^5.4|^6.4|^7.3|^8.0", + "twig/twig": "^1.44|^2.9|^3.0" + }, + "require-dev": { + "amazonwebservices/aws-sdk-for-php": "^1.0", + "aws/aws-sdk-php": "^2.4|^3.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/persistence": "^1.3|^2.0", + "enqueue/enqueue-bundle": "^0.9|^0.10", + "ext-gd": "*", + "league/flysystem": "^1.0|^2.0|^3.0", + "phpstan/phpstan": "^1.10.0", + "psr/cache": "^1.0|^2.0|^3.0", + "psr/log": "^1.0", + "symfony/asset": "^5.4|^6.4|^7.3|^8.0", + "symfony/browser-kit": "^5.4|^6.4|^7.3|^8.0", + "symfony/cache": "^5.4|^6.4|^7.3|^8.0", + "symfony/console": "^5.4|^6.4|^7.3|^8.0", + "symfony/form": "^5.4|^6.4|^7.3|^8.0", + "symfony/messenger": "^5.4|^6.4|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3", + "symfony/runtime": "^5.4|^6.4|^7.3|^8.0", + "symfony/templating": "^5.4|^6.4|^7.3|^8.0", + "symfony/validator": "^5.4|^6.4|^7.3|^8.0", + "symfony/yaml": "^5.4|^6.4|^7.3|^8.0" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "required for mongodb components", + "amazonwebservices/aws-sdk-for-php": "required to use AWS version 1 cache resolver", + "aws/aws-sdk-php": "required to use AWS version 2/3 cache resolver", + "doctrine/mongodb-odm": "required to use mongodb-backed doctrine components", + "enqueue/enqueue-bundle": "^0.9 add if you like to process images in background", + "ext-exif": "required to read EXIF metadata from images", + "ext-gd": "required to use gd driver", + "ext-gmagick": "required to use gmagick driver", + "ext-imagick": "required to use imagick driver", + "ext-json": "required to read JSON manifest versioning", + "ext-mongodb": "required for mongodb components", + "league/flysystem": "required to use FlySystem data loader or cache resolver", + "monolog/monolog": "A psr/log compatible logger is required to enable logging", + "rokka/imagine-vips": "required to use 'vips' driver", + "symfony/asset": "If you want to use asset versioning", + "symfony/messenger": "If you like to process images in background", + "symfony/templating": "required to use deprecated Templating component instead of Twig" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Liip\\ImagineBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Liip and other contributors", + "homepage": "https://github.com/liip/LiipImagineBundle/contributors" + } + ], + "description": "This bundle provides an image manipulation abstraction toolkit for Symfony-based projects.", + "homepage": "https://www.liip.ch", + "keywords": [ + "bundle", + "image", + "imagine", + "liip", + "manipulation", + "photos", + "pictures", + "symfony", + "transformation" + ], + "support": { + "issues": "https://github.com/liip/LiipImagineBundle/issues", + "source": "https://github.com/liip/LiipImagineBundle/tree/2.18.1" + }, + "time": "2026-10-07T20:50:10+00:00" + }, + { + "name": "marcj/topsort", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/marcj/topsort.php.git", + "reference": "387086c2db60ee0a27ac5df588c0f0b30c6bdc4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marcj/topsort.php/zipball/387086c2db60ee0a27ac5df588c0f0b30c6bdc4b", + "reference": "387086c2db60ee0a27ac5df588c0f0b30c6bdc4b", + "shasum": "" + }, + "require": { + "php": ">=5.4" + }, + "require-dev": { + "codeclimate/php-test-reporter": "dev-master", + "phpunit/phpunit": "~4.0", + "symfony/console": "~2.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "MJS\\TopSort\\": "src/", + "MJS\\TopSort\\Tests\\": "tests/Tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marc J. Schmidt", + "email": "marc@marcjschmidt.de" + } + ], + "description": "High-Performance TopSort/Dependency resolving algorithm", + "keywords": [ + "dependency resolving", + "topological sort", + "topsort" + ], + "support": { + "issues": "https://github.com/marcj/topsort.php/issues", + "source": "https://github.com/marcj/topsort.php/tree/1.1.0" + }, + "time": "2016-11-19T14:58:11+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, + { + "name": "pagerfanta/pagerfanta", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/BabDev/Pagerfanta.git", + "reference": "aa8aeff8d2f0e115901aa08368453d049605027e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/aa8aeff8d2f0e115901aa08368453d049605027e", + "reference": "aa8aeff8d2f0e115901aa08368453d049605027e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1", + "symfony/deprecation-contracts": "^2.1 || ^3.0" + }, + "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.5", + "doctrine/mongodb-odm": "<2.4", + "doctrine/orm": "<2.14", + "doctrine/phpcr-odm": "<1.7", + "ruflin/elastica": "<7.3", + "solarium/solarium": "<6.2", + "twig/twig": "<2.13" + }, + "replace": { + "pagerfanta/core": "self.version", + "pagerfanta/doctrine-collections-adapter": "self.version", + "pagerfanta/doctrine-dbal-adapter": "self.version", + "pagerfanta/doctrine-mongodb-odm-adapter": "self.version", + "pagerfanta/doctrine-orm-adapter": "self.version", + "pagerfanta/doctrine-phpcr-odm-adapter": "self.version", + "pagerfanta/elastica-adapter": "self.version", + "pagerfanta/solarium-adapter": "self.version", + "pagerfanta/twig": "self.version" + }, + "require-dev": { + "dg/bypass-finals": "^1.9", + "doctrine/collections": "^1.8 || ^2.0 || ^3.0", + "doctrine/dbal": "^3.5 || ^4.0", + "doctrine/mongodb-odm": "^2.4", + "doctrine/orm": "^2.14 || ^3.0", + "doctrine/phpcr-odm": "^1.7 || ^2.0", + "jackalope/jackalope-doctrine-dbal": "^1.9 || ^2.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", + "phpunit/phpunit": "10.5.64", + "rector/rector": "2.6.6", + "ruflin/elastica": "^7.3 || ^8.0", + "solarium/solarium": "^6.2", + "symfony/cache": "^5.4 || ^6.4 || ^7.4 || ^8.1", + "twig/twig": "^2.13 || ^3.0" + }, + "suggest": { + "twig/twig": "To integrate Pagerfanta with Twig" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pagerfanta\\": "lib/Core/", + "Pagerfanta\\Twig\\": "lib/Twig/", + "Pagerfanta\\Elastica\\": "lib/Adapter/Elastica/", + "Pagerfanta\\Solarium\\": "lib/Adapter/Solarium/", + "Pagerfanta\\Doctrine\\ORM\\": "lib/Adapter/Doctrine/ORM/", + "Pagerfanta\\Doctrine\\DBAL\\": "lib/Adapter/Doctrine/DBAL/", + "Pagerfanta\\Doctrine\\PHPCRODM\\": "lib/Adapter/Doctrine/PHPCRODM/", + "Pagerfanta\\Doctrine\\MongoDBODM\\": "lib/Adapter/Doctrine/MongoDBODM/", + "Pagerfanta\\Doctrine\\Collections\\": "lib/Adapter/Doctrine/Collections/" + }, + "exclude-from-classmap": [ + "lib/**/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pagination for PHP", + "keywords": [ + "page", + "pagination", + "paginator", + "paging" + ], + "support": { + "issues": "https://github.com/BabDev/Pagerfanta/issues", + "source": "https://github.com/BabDev/Pagerfanta/tree/v4.9.0" + }, + "funding": [ + { + "url": "https://github.com/mbabker", + "type": "github" + } + ], + "time": "2026-09-08T18:12:20+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/halite", + "version": "v5.1.4", + "source": { + "type": "git", + "url": "https://github.com/paragonie/halite.git", + "reference": "12e7d1ab50ef3cacc27b59140e75d2cf59e85f71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/halite/zipball/12e7d1ab50ef3cacc27b59140e75d2cf59e85f71", + "reference": "12e7d1ab50ef3cacc27b59140e75d2cf59e85f71", + "shasum": "" + }, + "require": { + "ext-json": "*", + "paragonie/constant_time_encoding": "^2|^3", + "paragonie/hidden-string": "^1|^2", + "paragonie/sodium_compat": "^1|^2", + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9", + "vimeo/psalm": "^6.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\Halite\\": "./src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "High-level cryptography interface powered by libsodium", + "homepage": "https://github.com/paragonie/halite", + "keywords": [ + "Argon2i", + "BLAKE", + "BLAKE2", + "BLAKE2b", + "Curve25519", + "Ed25519", + "X25519", + "Xsalsa20", + "argon2", + "cryptography", + "encryption", + "ext-sodium", + "hashing", + "libsodium", + "password", + "public-key", + "signatures", + "sodium" + ], + "support": { + "docs": "https://github.com/paragonie/halite/tree/master/doc", + "issues": "https://github.com/paragonie/halite/issues", + "source": "https://github.com/paragonie/halite/tree/v5.1.4" + }, + "time": "2025-09-19T03:39:32+00:00" + }, + { + "name": "paragonie/hidden-string", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/hidden-string.git", + "reference": "87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/hidden-string/zipball/87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7", + "reference": "87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2|^3", + "php": "^7.4|^8" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\HiddenString\\": "./src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Encapsulate strings in an object to hide them from stack traces", + "homepage": "https://github.com/paragonie/hidden-string", + "keywords": [ + "hidden", + "stack trace", + "string" + ], + "support": { + "issues": "https://github.com/paragonie/hidden-string/issues", + "source": "https://github.com/paragonie/hidden-string/tree/v2.2.0" + }, + "time": "2024-05-08T12:45:06+00:00" + }, + { + "name": "paragonie/sodium_compat", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "3246b36803c177847e677862fedeb7f937869adb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/3246b36803c177847e677862fedeb7f937869adb", + "reference": "3246b36803c177847e677862fedeb7f937869adb", + "shasum": "" + }, + "require": { + "php": "^8.1", + "php-64bit": "*" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^7|^8|^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "suggest": { + "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "ParagonIE\\Sodium\\": "namespaced/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" + } + ], + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", + "keywords": [ + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" + ], + "support": { + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.2" + }, + "time": "2026-08-18T23:46:48+00:00" + }, + { + "name": "payplug/payplug-php", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/payplug/payplug-php.git", + "reference": "ca089936ea5e442785465db6046ae88406a8db03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/payplug/payplug-php/zipball/ca089936ea5e442785465db6046ae88406a8db03", + "reference": "ca089936ea5e442785465db6046ae88406a8db03", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-openssl": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpdocumentor/phpdocumentor": "2.*", + "phpunit/phpunit": "5.7.*", + "rector/rector": "^1.2" + }, + "type": "library", + "autoload": { + "psr-0": { + "Payplug\\": "lib/" + }, + "psr-4": { + "Payplug\\": "lib/Payplug" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PayPlug", + "email": "support@payplug.com" + }, + { + "name": "Daniel FLOREZ MURILLO", + "email": "dmurillo@payplug.com" + } + ], + "description": "A simple PHP library for PayPlug public API.", + "homepage": "https://www.payplug.com", + "support": { + "issues": "https://github.com/payplug/payplug-php/issues", + "source": "https://github.com/payplug/payplug-php/tree/4.2.0" + }, + "time": "2015-05-06T00:00:00+00:00" + }, + { + "name": "payplug/unified-plugin-core", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/payplug/unified-plugin-core.git", + "reference": "05423ee4bae1bef5e1374ba1aeadf127c9bb0dca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/05423ee4bae1bef5e1374ba1aeadf127c9bb0dca", + "reference": "05423ee4bae1bef5e1374ba1aeadf127c9bb0dca", + "shasum": "" + }, + "require": { + "giggsey/libphonenumber-for-php": "8.13.45", + "giggsey/locale": "1.9", + "php": ">=7.4", + "symfony/polyfill-mbstring": "1.30.0 || ^1.31" + }, + "require-dev": { + "captainhook/captainhook": "^5.0", + "friendsofphp/php-cs-fixer": "3.60.0", + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "PayplugUnifiedCore\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Payplug", + "email": "support@payplug.com" + } + ], + "description": "Core foundations shared library for Payplug e-commerce plugins.", + "support": { + "issues": "https://github.com/payplug/unified-plugin-core/issues", + "source": "https://github.com/payplug/unified-plugin-core/tree/1.2.1" + }, + "time": "2026-10-08T07:20:41+00:00" + }, + { + "name": "payum/core", + "version": "1.7.7", + "target-dir": "Payum/Core", + "source": { + "type": "git", + "url": "https://github.com/Payum/Core.git", + "reference": "1319e4396ce91ff92c0fe3f4d5dfcb48ffdeaaf3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Payum/Core/zipball/1319e4396ce91ff92c0fe3f4d5dfcb48ffdeaaf3", + "reference": "1319e4396ce91ff92c0fe3f4d5dfcb48ffdeaaf3", + "shasum": "" + }, + "require": { + "alcohol/iso4217": "^3.1 || ^4.0", + "league/uri": "^6.4 || ^7.0", + "league/uri-components": "^2.2 || ^7.0", + "payum/iso4217": "^1.0", + "php": "^7.2 || ^8.0", + "php-http/client-implementation": "^1.0", + "php-http/message": "^1.0", + "psr/log": "^1 || ^2 || ^3", + "twig/twig": "^1.34|^2.4|^3.0" + }, + "require-dev": { + "defuse/php-encryption": "^2", + "doctrine/dbal": "^2", + "doctrine/orm": "2.*", + "doctrine/persistence": "^1.3.3|^2.0", + "ext-curl": "*", + "ext-pdo_sqlite": "*", + "laminas/laminas-db": "^2.0", + "omnipay/common": "^3.0", + "omnipay/dummy": "^3.0", + "payum/omnipay-v3-bridge": "^1.0", + "php-http/guzzle6-adapter": "^1.0", + "phpunit/phpunit": "^5.7", + "propel/propel1": "~1.7", + "symfony/cache": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/form": "^4.4|^5.0", + "symfony/http-foundation": "^4.4|^5.0", + "symfony/http-kernel": "^4.4|^5.0", + "symfony/phpunit-bridge": "^4.4|^5.0", + "symfony/routing": "^4.4|^5.0", + "symfony/validator": "^4.4|^5.0" + }, + "suggest": { + "defuse/php-encryption": "^2 If you want to encrypt gateways credentials in database", + "doctrine/mongodb-odm": "~2.0 If you want to store models to mongo doctrin2 ODM", + "doctrine/orm": "~2.3 If you want to store models to database using doctrin2 ORM", + "laminas/laminas-db": "~2.0 If you want to store models to Laminas Db ORM", + "monolog/monolog": "~1.0 If you want to use PSR-3 logger", + "payum/authorize-net-aim": "self.version If you want to use Authorize.Net AIM payment gateway", + "payum/be2bill": "self.version If you want to use be2bill payment gateway", + "payum/omnipay-v3-bridge": "^1 If you want to use omnipay's gateways", + "payum/payex": "self.version If you want to use payex payment gateway", + "payum/paypal-express-checkout-nvp": "self.version If you want to use paypal express checkout, digital goods or recurring payments", + "payum/paypal-ipn": "self.version If you want to use paypal instant payment notifications(Paypal IPN)", + "payum/paypal-pro-checkout-nvp": "self.version If you want to use paypal pro checkout", + "payum/paypal-rest": "self.version If you want to use paypal rest gateway", + "propel/propel": "If you want to store models to Propel2 ORM", + "propel/propel1": "~1.7 If you want to store models to Propel1 ORM", + "symfony/dependency-injection": "~2.8|~3.0 If you want to use container aware stuff", + "symfony/form": "~2.8|~3.0 If you want to use forms", + "symfony/http-foundation": "~2.8|~3.0 If you want to use HttpRequestVerifier or HttpResponse reply from symfony's bridge", + "symfony/http-kernel": "~2.8|~3.0 If you want to use HttpRequestVerifier from symfony's bridge", + "symfony/routing": "~2.8|~3.0 If you want to use TokenFactory from symfony's bridge" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-0": { + "Payum\\Core\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kotlyar Maksim", + "email": "kotlyar.maksim@gmail.com" + }, + { + "name": "Payum project", + "homepage": "https://payum.forma-pro.com/" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Payum/Payum/contributors" + } + ], + "description": "One million downloads of Payum already! Payum offers everything you need to work with payments. Friendly for all PHP frameworks (Symfony, Laravel, Laminas, Yii, Silex). Check more visiting site.", + "homepage": "https://payum.forma-pro.com/", + "keywords": [ + "authorize", + "capture", + "notify", + "payment", + "payout", + "recurring payment", + "refund", + "subscription", + "withdrawal" + ], + "support": { + "source": "https://github.com/Payum/Core/tree/1.7.7" + }, + "time": "2025-10-27T17:23:03+00:00" + }, + { + "name": "payum/iso4217", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/Payum/iso4217.git", + "reference": "faafdd2c5e799c673d7aa576aaf26fa2fb631014" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Payum/iso4217/zipball/faafdd2c5e799c673d7aa576aaf26fa2fb631014", + "reference": "faafdd2c5e799c673d7aa576aaf26fa2fb631014", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Payum\\ISO4217\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com" + }, + { + "name": "Kotlyar Maksim", + "email": "kotlyar.maksim@gmail.com" + }, + { + "name": "Payum project", + "homepage": "http://payum.org/" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Payum/Payum/contributors" + } + ], + "description": "ISO 4217 PHP Library", + "homepage": "https://payum.forma-pro.com/", + "keywords": [ + "4217", + "ISO 4217", + "currencies", + "iso", + "library" + ], + "support": { + "issues": "https://github.com/payum/iso4217/issues", + "source": "https://github.com/payum/iso4217" + }, + "time": "2022-02-27T10:14:23+00:00" + }, + { + "name": "payum/offline", + "version": "1.7.7", + "target-dir": "Payum/Offline", + "source": { + "type": "git", + "url": "https://github.com/Payum/Offline.git", + "reference": "9b10c121473962258a18ac85363de2047f8abbb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Payum/Offline/zipball/9b10c121473962258a18ac85363de2047f8abbb0", + "reference": "9b10c121473962258a18ac85363de2047f8abbb0", + "shasum": "" + }, + "require": { + "payum/core": "^1.5" + }, + "require-dev": { + "payum/core": "^1.5", + "phpunit/phpunit": "^5.7", + "symfony/phpunit-bridge": "^3.1|^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-0": { + "Payum\\Offline": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kotlyar Maksim", + "email": "kotlyar.maksim@gmail.com" + }, + { + "name": "Payum project", + "homepage": "https://payum.forma-pro.com/" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Payum/Offline/contributors" + } + ], + "description": "The Payum extension. It provides Offline payment integration.", + "homepage": "https://payum.forma-pro.com", + "keywords": [ + "invoice", + "offlile", + "payment" + ], + "support": { + "source": "https://github.com/Payum/Offline/tree/1.7.7" + }, + "time": "2025-10-27T17:23:03+00:00" + }, + { + "name": "payum/payum-bundle", + "version": "2.7.2", + "source": { + "type": "git", + "url": "https://github.com/Payum/PayumBundle.git", + "reference": "854ed36ab02c00c350b70ae3f7656d6a980f8a66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Payum/PayumBundle/zipball/854ed36ab02c00c350b70ae3f7656d6a980f8a66", + "reference": "854ed36ab02c00c350b70ae3f7656d6a980f8a66", + "shasum": "" + }, + "require": { + "payum/core": "^1.7.2", + "php": "^8.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/security-csrf": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "defuse/php-encryption": "^2", + "doctrine/orm": "^2.8 || ^3.0", + "nyholm/psr7": "^1.5", + "omnipay/common": "^3@dev", + "omnipay/dummy": "^3@alpha", + "omnipay/paypal": "^3@dev", + "payum/offline": "^1.7", + "payum/omnipay-v3-bridge": "dev-master", + "payum/paypal-express-checkout-nvp": "^1.7", + "payum/stripe": "^1.7", + "php-http/guzzle7-adapter": "^1.0", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.5 || ^10.0", + "psr/log": "^1 || ^2 || ^3", + "stripe/stripe-php": "~7.0", + "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-client": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/templating": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "twig/twig": "^2.0 || ^3.0" + }, + "suggest": { + "sonata-project/admin-bundle": "^3 If you want to configure payments in the backend." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.5-dev" + } + }, + "autoload": { + "psr-4": { + "Payum\\Bundle\\PayumBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kotlyar Maksim", + "email": "kotlyar.maksim@gmail.com" + }, + { + "name": "Payum project", + "homepage": "https://payum.forma-pro.com/" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Payum/PayumBundle/contributors" + } + ], + "description": "One million downloads of Payum already! Payum offers everything you need to work with payments. Check more visiting site.", + "homepage": "https://payum.forma-pro.com/", + "keywords": [ + "authorize.net", + "be2bill", + "instant notifications", + "klarna", + "offline", + "omnipay", + "payex", + "payment", + "paypal", + "paypal express checkout", + "paypal pro checkout", + "recurring payment", + "stripe", + "stripe checkout", + "stripe.js", + "symfony" + ], + "support": { + "issues": "https://github.com/Payum/PayumBundle/issues", + "source": "https://github.com/Payum/PayumBundle/tree/2.7.2" + }, + "time": "2026-07-07T12:12:48+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/guzzle7-adapter", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/guzzle7-adapter.git", + "reference": "03a415fde709c2f25539790fecf4d9a31bc3d0eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/guzzle7-adapter/zipball/03a415fde709c2f25539790fecf4d9a31bc3d0eb", + "reference": "03a415fde709c2f25539790fecf4d9a31bc3d0eb", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.0", + "php": "^7.3 | ^8.0", + "php-http/httplug": "^2.0", + "psr/http-client": "^1.0" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0", + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "php-http/client-integration-tests": "^3.0", + "php-http/message-factory": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^8.0|^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Adapter\\Guzzle7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Guzzle 7 HTTP Adapter", + "homepage": "http://httplug.io", + "keywords": [ + "Guzzle", + "http" + ], + "support": { + "issues": "https://github.com/php-http/guzzle7-adapter/issues", + "source": "https://github.com/php-http/guzzle7-adapter/tree/1.1.0" + }, + "time": "2024-11-26T11:14:36+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.2" + }, + "time": "2024-10-02T11:34:13+00:00" + }, + { + "name": "php-http/message-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/1.1.0" + }, + "abandoned": "psr/http-factory", + "time": "2023-04-14T14:16:17+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/link", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/link.git", + "reference": "84b159194ecfd7eaa472280213976e96415433f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7", + "reference": "84b159194ecfd7eaa472280213976e96415433f7", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "suggest": { + "fig/link-util": "Provides some useful PSR-13 utilities" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Link\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for HTTP links", + "homepage": "https://github.com/php-fig/link", + "keywords": [ + "http", + "http-link", + "link", + "psr", + "psr-13", + "rest" + ], + "support": { + "source": "https://github.com/php-fig/link/tree/2.0.1" + }, + "time": "2021-03-11T23:00:27+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "stof/doctrine-extensions-bundle", + "version": "v1.15.3", + "source": { + "type": "git", + "url": "https://github.com/stof/StofDoctrineExtensionsBundle.git", + "reference": "0f464d3e298ba97bcad219727ca1ee09ec8b30ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stof/StofDoctrineExtensionsBundle/zipball/0f464d3e298ba97bcad219727ca1ee09ec8b30ea", + "reference": "0f464d3e298ba97bcad219727ca1ee09ec8b30ea", + "shasum": "" + }, + "require": { + "gedmo/doctrine-extensions": "^3.21.0", + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/translation-contracts": "^2.5 || ^3.5" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^9.6.31", + "symfony/mime": "^6.4 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^v6.4.1 || ^7.0.1 || ^8.0", + "symfony/security-core": "^6.4 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "to use the ORM extensions", + "doctrine/mongodb-odm-bundle": "to use the MongoDB ODM extensions", + "symfony/mime": "To use the Mime component integration for Uploadable" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stof\\DoctrineExtensionsBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + } + ], + "description": "Integration of the gedmo/doctrine-extensions with Symfony", + "homepage": "https://github.com/stof/StofDoctrineExtensionsBundle", + "keywords": [ + "behaviors", + "doctrine2", + "extensions", + "gedmo", + "loggable", + "nestedset", + "sluggable", + "sortable", + "timestampable", + "translatable", + "tree" + ], + "support": { + "issues": "https://github.com/stof/StofDoctrineExtensionsBundle/issues", + "source": "https://github.com/stof/StofDoctrineExtensionsBundle/tree/v1.15.3" + }, + "time": "2026-01-23T08:45:07+00:00" + }, + { + "name": "sylius-labs/association-hydrator", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/SyliusLabs/AssociationHydrator.git", + "reference": "8dde1cde9cee92a40b883a565316a82a7bbbd7c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SyliusLabs/AssociationHydrator/zipball/8dde1cde9cee92a40b883a565316a82a7bbbd7c2", + "reference": "8dde1cde9cee92a40b883a565316a82a7bbbd7c2", + "shasum": "" + }, + "require": { + "doctrine/orm": "^2.20 || ^3.3", + "php": "^8.1", + "symfony/property-access": "^5.4 || ^6.4 || ^7.4 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SyliusLabs\\AssociationHydrator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "http://kamil.kokot.me" + } + ], + "description": "Doctrine ORM hydration performance optimization made easier.", + "support": { + "issues": "https://github.com/SyliusLabs/AssociationHydrator/issues", + "source": "https://github.com/SyliusLabs/AssociationHydrator/tree/v1.4.0" + }, + "time": "2026-02-06T14:09:00+00:00" + }, + { + "name": "sylius-labs/doctrine-migrations-extra-bundle", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/SyliusLabs/DoctrineMigrationsExtraBundle.git", + "reference": "30a87e08937220999f91ed160c99458210a1d26c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SyliusLabs/DoctrineMigrationsExtraBundle/zipball/30a87e08937220999f91ed160c99458210a1d26c", + "reference": "30a87e08937220999f91ed160c99458210a1d26c", + "shasum": "" + }, + "require": { + "doctrine/doctrine-migrations-bundle": "^3.0", + "doctrine/migrations": "^3.0", + "marcj/topsort": "^1.1", + "php": "^8.1", + "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "infection/infection": "^0.28", + "matthiasnoback/symfony-config-test": "^5.1", + "matthiasnoback/symfony-dependency-injection-test": "^5.1", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5" + }, + "type": "bundle", + "autoload": { + "psr-4": { + "SyliusLabs\\DoctrineMigrationsExtraBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "support": { + "issues": "https://github.com/SyliusLabs/DoctrineMigrationsExtraBundle/issues", + "source": "https://github.com/SyliusLabs/DoctrineMigrationsExtraBundle/tree/v0.2.2" + }, + "time": "2024-07-29T15:10:54+00:00" + }, + { + "name": "sylius/fixtures-bundle", + "version": "v1.10.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusFixturesBundle.git", + "reference": "f25f5356e62b5468cd95f5f3844b14c2b5ab00bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusFixturesBundle/zipball/f25f5356e62b5468cd95f5f3844b14c2b5ab00bf", + "reference": "f25f5356e62b5468cd95f5f3844b14c2b5ab00bf", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^1.2 || ^2.0", + "monolog/monolog": "^1.25 || ^2.1 || ^3", + "php": "^8.2", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/monolog-bridge": "^6.4 || ^7.4 || ^8.0", + "webmozart/assert": "^1.9" + }, + "require-dev": { + "doctrine/dbal": "^3.0 || ^4.0", + "doctrine/doctrine-bundle": "^2.1 || ^3.0", + "doctrine/orm": "^2.7 || ^3.0", + "matthiasnoback/symfony-config-test": "^4.2 || ^5.0 || ^6.0", + "matthiasnoback/symfony-dependency-injection-test": "^4.2 || ^5.0 || ^6.0", + "phpspec/phpspec": "^7.0 || ^8.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^9.4 || ^10.0 || ^11.0", + "rector/rector": "^2.0", + "sylius-labs/coding-standard": "^4.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Bundle\\FixturesBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "homepage": "https://kamilkokot.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Configurable fixtures for Symfony applications.", + "homepage": "https://sylius.com", + "keywords": [ + "fixtures", + "sylius", + "symfony" + ], + "support": { + "issues": "https://github.com/Sylius/SyliusFixturesBundle/issues", + "source": "https://github.com/Sylius/SyliusFixturesBundle/tree/v1.10.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-02-09T11:04:40+00:00" + }, + { + "name": "sylius/grid-bundle", + "version": "v1.16.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusGridBundle.git", + "reference": "22ca062fe16a2618a4e264f6c86162236d4ae50c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusGridBundle/zipball/22ca062fe16a2618a4e264f6c86162236d4ae50c", + "reference": "22ca062fe16a2618a4e264f6c86162236d4ae50c", + "shasum": "" + }, + "require": { + "php": "^8.2", + "sylius/registry": "^1.5", + "symfony/config": "^6.4 || ^7.2 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.2 || ^8.0", + "symfony/deprecation-contracts": "^2.2 || ^3.1", + "symfony/event-dispatcher": "^6.4 || ^7.2 || ^8.0", + "symfony/form": "^6.4 || ^7.2 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.2 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.2 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.2 || ^8.0", + "symfony/property-access": "^6.4 || ^7.2 || ^8.0", + "symfony/validator": "^6.4 || ^7.2 || ^8.0", + "webmozart/assert": "^1.9 || ^2.3" + }, + "conflict": { + "badev/pagerfanta-bundle": "<4.4", + "doctrine/dbal": "<2.11", + "doctrine/doctrine-bundle": "<2.0", + "doctrine/orm": "<2.8", + "doctrine/persistence": "<2.0", + "doctrine/phpcr-odm": "<1.5", + "jackalope/jackalope-doctrine-dbal": "<2.0", + "pagerfanta/core": "<3.7", + "pagerfanta/doctrine-dbal-adapter": "<3.7", + "pagerfanta/doctrine-orm-adapter": "<3.7", + "pagerfanta/doctrine-phpcr-odm-adapter": "<3.7", + "pagerfanta/pagerfanta": "<3.7", + "twig/twig": "<2.12" + }, + "replace": { + "sylius/grid": "self.version" + }, + "require-dev": { + "babdev/pagerfanta-bundle": "^4.4", + "doctrine/dbal": "^2.11 || ^3.0 || ^4.0", + "doctrine/doctrine-bundle": "^2.0 || ^3.0 || ^4.0", + "doctrine/orm": "^2.8 || ^3.0", + "doctrine/persistence": "^1.3 || ^2.0 || ^3.1 || ^4.0", + "doctrine/phpcr-odm": "^1.5 || ^2.0", + "jackalope/jackalope-doctrine-dbal": "^2.0", + "matthiasnoback/symfony-config-test": "^6.1", + "matthiasnoback/symfony-dependency-injection-test": "^6.1", + "pagerfanta/pagerfanta": "^3.7 || ^4.0", + "phparkitect/phparkitect": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.0", + "rector/rector": "^2.0", + "sylius-labs/coding-standard": "^4.0", + "symfony/browser-kit": "^6.4 || ^7.2 || ^8.0", + "symfony/console": "^6.4 || ^7.2 || ^8.0", + "symfony/css-selector": "^6.4 || ^7.2 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.2 || ^8.0", + "symfony/maker-bundle": "^1.36", + "symfony/polyfill-mbstring": "<1.22.0 || >1.22.0", + "symfony/security-csrf": "^6.4 || ^7.2 || ^8.0", + "symfony/translation": "^6.4 || ^7.2 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.2 || ^8.0", + "symplify/easy-coding-standard": "^13.0", + "twig/twig": "^2.12 || ^3.0", + "zenstruck/foundry": "^2.8" + }, + "suggest": { + "sylius/currency-bundle": "^1.7" + }, + "type": "symfony-bundle", + "extra": { + "symfony": { + "require": "7.4.*" + }, + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Component\\Grid\\": "src/Component/", + "Sylius\\Bundle\\GridBundle\\": "src/Bundle/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paweł Jędrzejewski", + "homepage": "https://pjedrzejewski.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Amazing grids with support of filters and custom fields integrated into Symfony.", + "homepage": "https://sylius.com", + "keywords": [ + "admin", + "api", + "crud", + "doctrine", + "grid", + "resource", + "sylius" + ], + "support": { + "issues": "https://github.com/Sylius/SyliusGridBundle/issues", + "source": "https://github.com/Sylius/SyliusGridBundle/tree/v1.16.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-07-13T09:44:06+00:00" + }, + { + "name": "sylius/mailer-bundle", + "version": "v2.2.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusMailerBundle.git", + "reference": "fd25981bd6653590ab380bde88a2a69b88803d61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusMailerBundle/zipball/fd25981bd6653590ab380bde88a2a69b88803d61", + "reference": "fd25981bd6653590ab380bde88a2a69b88803d61", + "shasum": "" + }, + "require": { + "php": "^8.2", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher-contracts": "^3.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.3", + "webmozart/assert": "^1.9" + }, + "replace": { + "sylius/mailer": "self.version" + }, + "require-dev": { + "matthiasnoback/symfony-dependency-injection-test": "^6.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-webmozart-assert": "1.2.11", + "phpunit/phpunit": "^11.5", + "sylius-labs/coding-standard": "^4.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", + "symfony/mailer": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0" + }, + "suggest": { + "symfony/translation": "To use the translation features for testing purposes" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Component\\Mailer\\": "src/Component/", + "Sylius\\Bundle\\MailerBundle\\": "src/Bundle/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paweł Jędrzejewski", + "homepage": "https://pjedrzejewski.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Mailers and e-mail template management for Symfony projects.", + "homepage": "https://sylius.com", + "keywords": [ + "email", + "mailer", + "symfony" + ], + "support": { + "issues": "https://github.com/Sylius/SyliusMailerBundle/issues", + "source": "https://github.com/Sylius/SyliusMailerBundle/tree/v2.2.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-09-18T13:34:23+00:00" + }, + { + "name": "sylius/pdf-generation-bundle", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/Sylius/PdfGenerationBundle.git", + "reference": "465f0d9c54faebcb1828d6ee2495356053ed7177" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/PdfGenerationBundle/zipball/465f0d9c54faebcb1828d6ee2495356053ed7177", + "reference": "465f0d9c54faebcb1828d6ee2495356053ed7177", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.0" + }, + "require-dev": { + "dompdf/dompdf": "^3.1", + "ext-curl": "*", + "gotenberg/gotenberg-php": "^2.17", + "knplabs/knp-gaufrette-bundle": "^0.9.0", + "knplabs/knp-snappy-bundle": "^1.10", + "league/flysystem-bundle": "^3.0", + "matthiasnoback/symfony-config-test": "^5.1 || ^6.0", + "matthiasnoback/symfony-dependency-injection-test": "^5.1 || ^6.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-symfony": "^1.4", + "phpunit/phpunit": "^10.5", + "sylius-labs/coding-standard": "^4.4", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/http-client": "^7.4" + }, + "suggest": { + "dompdf/dompdf": "Required for the Dompdf adapter", + "knplabs/knp-gaufrette-bundle": "Required for the Gaufrette storage type", + "knplabs/knp-snappy-bundle": "Required for the KnpSnappy adapter (wkhtmltopdf)", + "league/flysystem-bundle": "Required for the Flysystem storage type" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Sylius\\PdfGenerationBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PDF generation bundle with swappable renderer abstraction.", + "keywords": [ + "pdf", + "symfony" + ], + "support": { + "issues": "https://github.com/Sylius/PdfGenerationBundle/issues", + "source": "https://github.com/Sylius/PdfGenerationBundle/tree/v1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-03-18T11:33:47+00:00" + }, + { + "name": "sylius/refund-plugin", + "version": "v2.2.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/RefundPlugin.git", + "reference": "0ca6789ac408f43e05452ee8595c1fe219c3c3d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/RefundPlugin/zipball/0ca6789ac408f43e05452ee8595c1fe219c3c3d2", + "reference": "0ca6789ac408f43e05452ee8595c1fe219c3c3d2", + "shasum": "" + }, + "require": { + "knplabs/gaufrette": "^0.11 || ^1.0", + "knplabs/knp-gaufrette-bundle": "^0.9 || ^1.0", + "knplabs/knp-snappy-bundle": "^1.10", + "myclabs/php-enum": "^1.8", + "php": "^8.2", + "php-http/discovery": "^1.20", + "sylius/pdf-generation-bundle": "^1.0", + "sylius/resource-bundle": "^1.12", + "sylius/sylius": "^2.0", + "sylius/telemetry": "^1.0", + "symfony/messenger": "^6.4 || ^7.4 || ^8.0" + }, + "require-dev": { + "behat/behat": "^3.22 || 4.0.0-alpha1", + "behat/mink": "^1.10 || ^1.13", + "dmore/chrome-mink-driver": "^2.7", + "friends-of-behat/mink-browserkit-driver": "^1.6.3", + "friends-of-behat/mink-debug-extension": "^2.2", + "friends-of-behat/mink-extension": "^2.7 || 3.0.0-alpha1", + "friends-of-behat/page-object-extension": "^0.3", + "friends-of-behat/symfony-extension": "^2.6.2", + "friends-of-behat/variadic-extension": "^1.6", + "matthiasnoback/symfony-config-test": "^6.1", + "matthiasnoback/symfony-dependency-injection-test": "^6.2", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^10.5", + "sylius-labs/behat-chrome-extension": "^1.5", + "sylius-labs/coding-standard": "^4.2", + "sylius/test-application": "^2.0.0@alpha", + "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", + "symfony/debug-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.4 || ^8.0", + "symfony/intl": "^6.4 || ^7.4 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/webpack-encore-bundle": "^2.2" + }, + "type": "sylius-plugin", + "extra": { + "symfony": { + "require": "~7.4.0", + "allow-contrib": false + }, + "public-dir": "vendor/sylius/test-application/public", + "branch-alias": { + "dev-2.0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\RefundPlugin\\": "src/", + "Tests\\Sylius\\RefundPlugin\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mateusz Zalewski", + "homepage": "http://mpzalewski.com.pl" + }, + { + "name": "Bartosz Pietrzak", + "homepage": "https://github.com/bartoszpietrzak1994" + }, + { + "name": "Sylius Team", + "email": "team@sylius.com" + } + ], + "description": "Plugin provides basic refunds functionality for Sylius application.", + "keywords": [ + "e-commerce", + "refunds", + "sylius", + "sylius-plugin", + "symfony" + ], + "support": { + "issues": "https://github.com/Sylius/RefundPlugin/issues", + "source": "https://github.com/Sylius/RefundPlugin/tree/v2.2.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-10-06T09:28:15+00:00" + }, + { + "name": "sylius/registry", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/Sylius/Registry.git", + "reference": "e4f44f418f48d43e1b969e878fd962e7034aa3ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/Registry/zipball/e4f44f418f48d43e1b969e878fd962e7034aa3ad", + "reference": "e4f44f418f48d43e1b969e878fd962e7034aa3ad", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpspec/phpspec": "^7.0", + "sylius-labs/coding-standard": "^3.2.2", + "vimeo/psalm": "4.4.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Component\\Registry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paweł Jędrzejewski", + "homepage": "http://pjedrzejewski.com" + }, + { + "name": "Sylius project", + "homepage": "http://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "http://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Services registry.", + "homepage": "http://sylius.com", + "keywords": [ + "registry", + "services" + ], + "support": { + "issues": "https://github.com/Sylius/Registry/issues", + "source": "https://github.com/Sylius/Registry/tree/v1.6.0" + }, + "time": "2021-02-01T15:36:19+00:00" + }, + { + "name": "sylius/resource-bundle", + "version": "v1.14.2", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusResourceBundle.git", + "reference": "9ec90cdc58a053cccdd81ebf9584bdf5b66563f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusResourceBundle/zipball/9ec90cdc58a053cccdd81ebf9584bdf5b66563f1", + "reference": "9ec90cdc58a053cccdd81ebf9584bdf5b66563f1", + "shasum": "" + }, + "require": { + "babdev/pagerfanta-bundle": "^4.4", + "doctrine/collections": "^2.2", + "doctrine/event-manager": "^1.1 || ^2.0", + "doctrine/inflector": "^2.0", + "doctrine/persistence": "^3.3 || ^4.0", + "php": "^8.2", + "sylius/registry": "^1.2", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/deprecation-contracts": "^3.5", + "symfony/expression-language": "^6.4 || ^7.4 || ^8.0", + "symfony/form": "^6.4 || ^7.4 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", + "symfony/intl": "^6.4 || ^7.4 || ^8.0", + "symfony/routing": "^6.4 || ^7.4 || ^8.0", + "symfony/security-core": "^6.4 || ^7.4 || ^8.0", + "symfony/security-csrf": "^6.4 || ^7.4 || ^8.0", + "symfony/string": "^6.4 || ^7.4 || ^8.0", + "symfony/translation": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/validator": "^6.4 || ^7.4 || ^8.0", + "symfony/yaml": "^6.4 || ^7.4 || ^8.0", + "webmozart/assert": "^1.11", + "willdurand/negotiation": "^3.1" + }, + "conflict": { + "behat/transliterator": "<1.2", + "doctrine/doctrine-bundle": "<2.0", + "doctrine/orm": "<2.18", + "doctrine/phpcr-odm": "<2.1", + "friendsofsymfony/rest-bundle": "<3.7", + "gedmo/doctrine-extensions": "<3.17.1", + "jms/serializer-bundle": "<5.5", + "pagerfanta/pagerfanta": "<4.4", + "symfony/workflow": "<6.4 || >=7.0,<7.4", + "twig/twig": "<3.0", + "willdurand/hateoas-bundle": "<2.5 || ^3.0", + "winzou/state-machine-bundle": "<0.6.2" + }, + "replace": { + "sylius/resource": "self.version" + }, + "require-dev": { + "coduo/php-matcher": "^6.0", + "doctrine/data-fixtures": "^2.0", + "doctrine/doctrine-bundle": "^2.13 || ^3.0 || ^4.0", + "doctrine/orm": "^2.18 || ^3.3", + "jackalope/jackalope": "^2.0", + "jackalope/jackalope-doctrine-dbal": "^2.0", + "matthiasnoback/symfony-dependency-injection-test": "^6.1.0", + "openlss/lib-array2xml": "^1.0", + "pagerfanta/pagerfanta": "^4.4", + "phpcr/phpcr": "^2.1", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.0", + "rector/rector": "^0.18.2", + "sylius-labs/coding-standard": "^4.4", + "sylius/grid-bundle": "^1.13 || ^1.15@alpha", + "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/css-selector": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/messenger": "^6.4 || ^7.4 || ^8.0", + "symfony/security-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/serializer": "^6.4 || ^7.4 || ^8.0", + "symfony/stopwatch": "^6.4 || ^7.4 || ^8.0", + "symfony/uid": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.14", + "zenstruck/foundry": "^2.3" + }, + "suggest": { + "doctrine/orm": "^2.20", + "sylius/locale": "^1.0" + }, + "type": "symfony-bundle", + "extra": { + "symfony": { + "require": "8.0.*" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Resource\\": "src/Component/src/", + "Sylius\\Component\\Resource\\": "src/Component/legacy/src/", + "Sylius\\Bundle\\ResourceBundle\\": "src/Bundle/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paweł Jędrzejewski", + "homepage": "https://pjedrzejewski.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Resource component for Sylius.", + "homepage": "https://sylius.com", + "keywords": [ + "persistence", + "resource", + "storage", + "sylius" + ], + "support": { + "issues": "https://github.com/Sylius/SyliusResourceBundle/issues", + "source": "https://github.com/Sylius/SyliusResourceBundle/tree/v1.14.2" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-06-22T05:19:23+00:00" + }, + { + "name": "sylius/sylius", + "version": "v2.2.10", + "source": { + "type": "git", + "url": "https://github.com/Sylius/Sylius.git", + "reference": "1a5d6da030245dbb23be1feff055f2c125bd9b12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/Sylius/zipball/1a5d6da030245dbb23be1feff055f2c125bd9b12", + "reference": "1a5d6da030245dbb23be1feff055f2c125bd9b12", + "shasum": "" + }, + "require": { + "api-platform/doctrine-orm": "^4.2.1", + "api-platform/state": "^4.2.1", + "api-platform/symfony": "^4.2.1", + "babdev/pagerfanta-bundle": "^4.6", + "behat/transliterator": "^1.5", + "doctrine/collections": "^2.2", + "doctrine/common": "^3.2", + "doctrine/dbal": "^3.9", + "doctrine/doctrine-bundle": "^2.13", + "doctrine/doctrine-migrations-bundle": "^3.3", + "doctrine/event-manager": "^2.0", + "doctrine/inflector": "^2.0", + "doctrine/migrations": "^3.8", + "doctrine/orm": "^2.18 || ^3.5", + "doctrine/persistence": "^3.3", + "egulias/email-validator": "^4.0", + "ext-dom": "*", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-hash": "*", + "ext-intl": "*", + "ext-json": "*", + "ext-simplexml": "*", + "ext-sodium": "*", + "fakerphp/faker": "^1.23", + "friendsofphp/proxy-manager-lts": "^1.0", + "gedmo/doctrine-extensions": "^3.20", + "guzzlehttp/guzzle": "^7.9", + "guzzlehttp/psr7": "^2.5", + "knplabs/gaufrette": "^0.11", + "knplabs/knp-gaufrette-bundle": "^0.9", + "knplabs/knp-menu": "^3.5", + "knplabs/knp-menu-bundle": "^3.4", + "laminas/laminas-stdlib": "^3.19", + "league/flysystem-bundle": "^3.3", + "lexik/jwt-authentication-bundle": "^3.1", + "liip/imagine-bundle": "^2.15", + "pagerfanta/pagerfanta": "^4.0", + "paragonie/halite": "^5.0", + "payum/offline": "^1.7.5", + "payum/payum-bundle": "^2.6", + "php": "^8.2", + "php-http/discovery": "^1.20", + "php-http/guzzle7-adapter": "^1.0", + "php-http/httplug": "^2.4", + "php-http/message-factory": "^1.1", + "psr/cache": "^3.0", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^3.0", + "ramsey/uuid": "^4.7", + "stof/doctrine-extensions-bundle": "^1.12", + "sylius-labs/association-hydrator": "^1.2", + "sylius-labs/doctrine-migrations-extra-bundle": "^0.2", + "sylius/fixtures-bundle": "^1.9", + "sylius/grid": "^1.13", + "sylius/grid-bundle": "^1.13", + "sylius/mailer": "^2.1", + "sylius/mailer-bundle": "^2.1", + "sylius/registry": "^1.6", + "sylius/resource": "^1.12", + "sylius/resource-bundle": "^1.12", + "sylius/theme-bundle": "^2.4", + "sylius/twig-extra": "^0.9", + "sylius/twig-hooks": "^0.9", + "symfony/asset": "^6.4 || ^7.4", + "symfony/cache-contracts": "^3.5", + "symfony/clock": "^6.4 || ^7.4", + "symfony/config": "^6.4 || ^7.4", + "symfony/console": "^6.4.1 || ^7.4", + "symfony/dependency-injection": "^6.4.1 || ^7.4", + "symfony/deprecation-contracts": "^3.5", + "symfony/doctrine-bridge": "^6.4 || ^7.4", + "symfony/doctrine-messenger": "^6.4.1 || ^7.4", + "symfony/event-dispatcher": "^6.4 || ^7.4", + "symfony/expression-language": "^6.4 || ^7.4", + "symfony/filesystem": "^6.4 || ^7.4", + "symfony/finder": "^6.4 || ^7.4", + "symfony/form": "^6.4.1 || ^7.4", + "symfony/framework-bundle": "^6.4.1 || ^7.4", + "symfony/http-client": "^6.4 || ^7.4", + "symfony/http-foundation": "^6.4 || ^7.4", + "symfony/http-kernel": "^6.4.1 || ^7.4", + "symfony/intl": "^6.4 || ^7.4", + "symfony/mailer": "^6.4 || ^7.4", + "symfony/messenger": "^6.4 || ^7.4", + "symfony/monolog-bundle": "^3.8.0", + "symfony/options-resolver": "^6.4 || ^7.4", + "symfony/password-hasher": "^6.4 || ^7.4", + "symfony/polyfill-iconv": "^1.31", + "symfony/polyfill-intl-icu": "^1.31", + "symfony/polyfill-mbstring": "^1.31", + "symfony/process": "^6.4 || ^7.4", + "symfony/property-access": "^6.4 || ^7.4", + "symfony/property-info": "^6.4 || ^7.4", + "symfony/proxy-manager-bridge": "^6.4 || ^7.4", + "symfony/routing": "^6.4.1 || ^7.4", + "symfony/security-bundle": "^6.4 || ^7.4", + "symfony/security-core": "^6.4 || ^7.4", + "symfony/security-csrf": "^6.4 || ^7.4", + "symfony/security-http": "^6.4 || ^7.4", + "symfony/serializer": "^6.4 || ^7.4", + "symfony/service-contracts": "^3.5", + "symfony/stimulus-bundle": "^2.25", + "symfony/string": "^6.4 || ^7.4", + "symfony/translation": "^6.4 || ^7.4", + "symfony/translation-contracts": "^3.3", + "symfony/twig-bundle": "^6.4 || ^7.4", + "symfony/uid": "^6.4 || ^7.4", + "symfony/ux-autocomplete": "^2.25", + "symfony/ux-icons": "^2.25", + "symfony/ux-live-component": "^2.25", + "symfony/ux-twig-component": "^2.25", + "symfony/validator": "^6.4 || ^7.4", + "symfony/webpack-encore-bundle": "^2.2", + "symfony/workflow": "^6.4 || ^7.4", + "symfony/yaml": "^6.4 || ^7.4", + "symfonycasts/dynamic-forms": "^0.1", + "twig/extra-bundle": "^3.16", + "twig/intl-extra": "^3.16", + "twig/string-extra": "^3.16", + "twig/twig": "^3.16", + "webmozart/assert": "^1.11" + }, + "conflict": { + "api-platform/serializer": "4.2.17", + "api-platform/symfony": "4.3.16", + "doctrine/orm": "2.20.7 || 3.5.3 || 3.6.8 || >=3.7", + "symfony/ux-live-component": "2.28.0 || 2.28.1" + }, + "replace": { + "sylius/addressing": "self.version", + "sylius/addressing-bundle": "self.version", + "sylius/admin-bundle": "self.version", + "sylius/api-bundle": "self.version", + "sylius/attribute": "self.version", + "sylius/attribute-bundle": "self.version", + "sylius/channel": "self.version", + "sylius/channel-bundle": "self.version", + "sylius/core": "self.version", + "sylius/core-bundle": "self.version", + "sylius/currency": "self.version", + "sylius/currency-bundle": "self.version", + "sylius/customer": "self.version", + "sylius/customer-bundle": "self.version", + "sylius/inventory": "self.version", + "sylius/inventory-bundle": "self.version", + "sylius/locale": "self.version", + "sylius/locale-bundle": "self.version", + "sylius/money-bundle": "self.version", + "sylius/order": "self.version", + "sylius/order-bundle": "self.version", + "sylius/payment": "self.version", + "sylius/payment-bundle": "self.version", + "sylius/payum-bundle": "self.version", + "sylius/product": "self.version", + "sylius/product-bundle": "self.version", + "sylius/promotion": "self.version", + "sylius/promotion-bundle": "self.version", + "sylius/review": "self.version", + "sylius/review-bundle": "self.version", + "sylius/shipping": "self.version", + "sylius/shipping-bundle": "self.version", + "sylius/shop-bundle": "self.version", + "sylius/state-machine-abstraction": "self.version", + "sylius/taxation": "self.version", + "sylius/taxation-bundle": "self.version", + "sylius/taxonomy": "self.version", + "sylius/taxonomy-bundle": "self.version", + "sylius/ui-bundle": "self.version", + "sylius/user": "self.version", + "sylius/user-bundle": "self.version" + }, + "require-dev": { + "behat/behat": "^3.22", + "behat/mink-selenium2-driver": "^1.7", + "consolidation/robo": "^4.0 || ^5.0", + "dbrekelmans/bdi": "^1.3", + "dmore/behat-chrome-extension": "^1.4", + "dmore/chrome-mink-driver": "^2.9", + "doctrine/cache": "^2.2", + "doctrine/data-fixtures": "^1.7", + "friends-of-behat/mink": "^1.11", + "friends-of-behat/mink-browserkit-driver": "^1.6", + "friends-of-behat/mink-debug-extension": "^2.1", + "friends-of-behat/mink-extension": "^2.7", + "friends-of-behat/page-object-extension": "^0.3", + "friends-of-behat/symfony-extension": "^2.6.2", + "friends-of-behat/variadic-extension": "^1.6", + "hwi/oauth-bundle": "^2.2", + "lchrusciel/api-test-case": "^5.3", + "matthiasnoback/symfony-config-test": "^6.0", + "matthiasnoback/symfony-dependency-injection-test": "^6.0", + "nyholm/psr7": "^1.8", + "phparkitect/phparkitect": "^0.6", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "psr/event-dispatcher": "^1.0", + "rector/rector": "^2.0", + "robertfausk/behat-panther-extension": "^1.1", + "sylius-labs/coding-standard": "^4.4", + "sylius-labs/suite-tags-extension": "~0.2", + "symfony/browser-kit": "^6.4 || ^7.4", + "symfony/debug-bundle": "^6.4 || ^7.4", + "symfony/dotenv": "^6.4 || ^7.4", + "symfony/flex": "^2.4", + "symfony/runtime": "^6.4 || ^7.4", + "symfony/web-profiler-bundle": "^6.4 || ^7.4", + "symplify/monorepo-builder": "^11.0" + }, + "suggest": { + "ext-iconv": "For better performance than using Symfony Polyfill Component", + "ext-intl": "For better performance than using Symfony Polyfill Component", + "ext-mbstring": "For better performance than using Symfony Polyfill Component", + "hwi/oauth-bundle": "If you want to use Facebook login (see https://docs.sylius.com/en/latest/cookbook/shop/facebook-login.html)", + "winzou/state-machine": "If you want to use Winzou State Machine (^0.4)", + "winzou/state-machine-bundle": "If you want to use Winzou State Machine (^0.6)" + }, + "type": "library", + "extra": { + "symfony": { + "require": "^7.4", + "allow-contrib": false + }, + "branch-alias": { + "dev-main": "2.2-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Behat\\": "src/Sylius/Behat/", + "Sylius\\Bundle\\": "src/Sylius/Bundle/", + "Sylius\\Component\\": "src/Sylius/Component/", + "Sylius\\Abstraction\\StateMachine\\": "src/Sylius/Abstraction/StateMachine/src" + }, + "exclude-from-classmap": [ + "src/Sylius/*/*/Tests/", + "src/Sylius/Component/Core/Test/Tests/", + "src/Sylius/*/*/test/", + "src/Sylius/*/*/phparkitect.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paweł Jędrzejewski", + "homepage": "https://pjedrzejewski.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "E-Commerce platform for PHP, based on Symfony framework.", + "homepage": "https://sylius.com", + "support": { + "issues": "https://github.com/Sylius/Sylius/issues", + "source": "https://github.com/Sylius/Sylius/tree/v2.2.10" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-09-28T12:53:01+00:00" + }, + { + "name": "sylius/telemetry", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/Telemetry.git", + "reference": "45a3f6fa35bf8c56374d71acb525b55fe65def76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/Telemetry/zipball/45a3f6fa35bf8c56374d71acb525b55fe65def76", + "reference": "45a3f6fa35bf8c56374d71acb525b55fe65def76", + "shasum": "" + }, + "require": { + "php": "^8.0", + "sylius/sylius": "^1.12 || ^2.0", + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/cache": "^2.2", + "phpunit/phpunit": "^10.5 || ^11.0", + "sylius/test-application": "~1.14.0@alpha || ^2.0.0@alpha", + "symfony/browser-kit": "^6.4 || ^7.4", + "symfony/debug-bundle": "^6.4 || ^7.4", + "symfony/dotenv": "^6.4 || ^7.4", + "symfony/http-kernel": "^6.4 || ^7.4", + "symfony/runtime": "^6.4 || ^7.4", + "symfony/var-exporter": "^6.4 || ^7.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.4" + }, + "type": "library", + "extra": { + "public-dir": "vendor/sylius/test-application/public" + }, + "autoload": { + "psr-4": { + "Sylius\\Telemetry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Telemetry integration for official Sylius plugins", + "support": { + "issues": "https://github.com/Sylius/Telemetry/issues", + "source": "https://github.com/Sylius/Telemetry/tree/v1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-02-03T09:10:53+00:00" + }, + { + "name": "sylius/theme-bundle", + "version": "v2.5.0", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusThemeBundle.git", + "reference": "3249ad4ef6d60ccc96fe5238707572eb78e3aa99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusThemeBundle/zipball/3249ad4ef6d60ccc96fe5238707572eb78e3aa99", + "reference": "3249ad4ef6d60ccc96fe5238707572eb78e3aa99", + "shasum": "" + }, + "require": { + "php": "^8.2", + "symfony/asset": "^6.4 || ^7.4 || ^8.0", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", + "symfony/finder": "^6.4 || ^7.4 || ^8.0", + "symfony/form": "^6.4 || ^7.4 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.4 || ^8.0", + "symfony/service-contracts": "^3.0", + "symfony/translation": "^6.4 || ^7.4 || ^8.0", + "symfony/translation-contracts": "^3.0" + }, + "require-dev": { + "matthiasnoback/symfony-config-test": "^5.0 || ^6.1", + "matthiasnoback/symfony-dependency-injection-test": "^5.0 || ^6.1", + "mikey179/vfsstream": "^1.6", + "phpspec/phpspec": "^7.0 || ^8.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "rector/rector": "^1.0", + "sylius-labs/coding-standard": "^4.0.2", + "symfony/browser-kit": "^6.4 || ^7.4 || ^8.0", + "symfony/security-csrf": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.5-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\Bundle\\ThemeBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "homepage": "https://kamilkokot.com" + }, + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Sylius/contributors" + } + ], + "description": "Themes management for Symfony projects.", + "homepage": "https://sylius.com", + "keywords": [ + "themes", + "theming" + ], + "support": { + "issues": "https://github.com/Sylius/SyliusThemeBundle/issues", + "source": "https://github.com/Sylius/SyliusThemeBundle/tree/v2.5.0" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-02-06T13:40:45+00:00" + }, + { + "name": "sylius/twig-extra", + "version": "v0.9.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/TwigExtra.git", + "reference": "57b582c9ca33f454e1b24f49ecbc442053cb3ac2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/TwigExtra/zipball/57b582c9ca33f454e1b24f49ecbc442053cb3ac2", + "reference": "57b582c9ca33f454e1b24f49ecbc442053cb3ac2", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0", + "symfony/ux-twig-component": "^2.17" + }, + "conflict": { + "sylius/ui-bundle": "<2.0" + }, + "require-dev": { + "matthiasnoback/symfony-dependency-injection-test": "^5.1", + "phpunit/phpunit": "^9.6", + "symfony/browser-kit": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/css-selector": "^6.4 || ^7.0", + "symfony/dotenv": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/runtime": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0" + }, + "type": "library", + "extra": { + "symfony": { + "require": "7.1.*" + }, + "branch-alias": { + "dev-main": "0.9-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\TwigExtra\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Stack/contributors" + } + ], + "description": "Additional Twig extensions for your Symfony projects", + "support": { + "issues": "https://github.com/Sylius/TwigExtra/issues", + "source": "https://github.com/Sylius/TwigExtra/tree/v0.9.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2025-09-26T08:18:45+00:00" + }, + { + "name": "sylius/twig-hooks", + "version": "v0.9.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/TwigHooks.git", + "reference": "c047961d09c0bc084e241bae137630bcbf40667b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/TwigHooks/zipball/c047961d09c0bc084e241bae137630bcbf40667b", + "reference": "c047961d09c0bc084e241bae137630bcbf40667b", + "shasum": "" + }, + "require": { + "laminas/laminas-stdlib": "^3.18", + "php": "^8.1", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0", + "symfony/ux-live-component": "^2.17", + "symfony/ux-twig-component": "^2.17", + "twig/twig": "^2.15 || ^3.0", + "webmozart/assert": "^1.9" + }, + "require-dev": { + "matthiasnoback/symfony-config-test": "^5.1", + "matthiasnoback/symfony-dependency-injection-test": "^5.1", + "phpunit/phpunit": "^9.6", + "symfony/console": "^6.4 || ^7.0", + "symfony/dom-crawler": "^6.4 || ^7.0", + "symfony/dotenv": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/runtime": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0" + }, + "suggest": { + "symfony/ux-twig-component": "Symfony's package providing Twig components." + }, + "type": "library", + "extra": { + "symfony": { + "require": "7.1.*" + }, + "branch-alias": { + "dev-main": "0.9-dev" + } + }, + "autoload": { + "psr-4": { + "Sylius\\TwigHooks\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sylius project", + "homepage": "https://sylius.com" + }, + { + "name": "Community contributions", + "homepage": "https://github.com/Sylius/Stack/contributors" + } + ], + "description": "Composable Twig layouts", + "support": { + "source": "https://github.com/Sylius/TwigHooks/tree/v0.9.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2025-09-26T08:18:45+00:00" + }, + { + "name": "symfony/asset", + "version": "v6.4.34", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "1bd59aa278691b6310ca56b996cf6e2619a6a347" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/1bd59aa278691b6310ca56b996cf6e2619a6a347", + "reference": "1bd59aa278691b6310ca56b996cf6e2619a6a347", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/http-foundation": "<5.4" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v6.4.34" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-07T09:15:39+00:00" + }, + { + "name": "symfony/cache", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "7ce0de35c828d29a5602134cdcf82b9c301b198f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/7ce0de35c828d29a5602134cdcf82b9c301b198f", + "reference": "7ce0de35c828d29a5602134cdcf82b9c301b198f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.3.6|^7.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/var-dumper": "<5.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^0.18", + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T12:25:26+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/clock", + "version": "v6.4.30", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "fb2df4bc9e3037c4765ba7fd29e00167001a9b68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/fb2df4bc9e3037c4765ba7fd29e00167001a9b68", + "reference": "fb2df4bc9e3037c4765ba7fd29e00167001a9b68", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v6.4.30" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-11T21:24:34+00:00" + }, + { + "name": "symfony/config", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:38:57+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", + "reference": "0f758dfab4608cac6a342b7e7f80cb84c9cc98ca", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-18T10:23:48+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "c7ffb6da26739b461ae18dca3438bc68c35cd2a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/c7ffb6da26739b461ae18dca3438bc68c35cd2a0", + "reference": "c7ffb6da26739b461ae18dca3438bc68c35cd2a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.1", + "symfony/finder": "<5.4", + "symfony/proxy-manager-bridge": "<6.3", + "symfony/yaml": "<5.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.1|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T10:29:26+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/doctrine-bridge", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "1fa8f2328ad79314c036bc61754d2f86112e7360" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/1fa8f2328ad79314c036bc61754d2f86112e7360", + "reference": "1fa8f2328ad79314c036bc61754d2f86112e7360", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^1.2|^2", + "doctrine/persistence": "^2.5|^3.1|^4", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<2.15", + "symfony/cache": "<5.4", + "symfony/dependency-injection": "<6.2", + "symfony/form": "<5.4.38|>=6,<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.3", + "symfony/http-kernel": "<6.2", + "symfony/lock": "<6.3", + "symfony/messenger": "<5.4", + "symfony/property-info": "<5.4|>=8", + "symfony/security-bundle": "<5.4", + "symfony/security-core": "<6.4", + "symfony/validator": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/orm": "^2.15|^3", + "psr/log": "^1|^2|^3", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^6.2|^7.0", + "symfony/doctrine-messenger": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4.38|^6.4.6|^7.0.6", + "symfony/http-kernel": "^6.3|^7.0", + "symfony/lock": "^6.3|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/proxy-manager-bridge": "^6.4", + "symfony/security-core": "^6.4|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Doctrine with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T17:32:40+00:00" + }, + { + "name": "symfony/doctrine-messenger", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "77d265ac40864f9f0488a0233ac940ad5ff4b0e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/77d265ac40864f9f0488a0233ac940ad5ff4b0e1", + "reference": "77d265ac40864f9f0488a0233ac940ad5ff4b0e1", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^2.13|^3|^4", + "php": ">=8.1", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3|^4", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.0|^7.0" + }, + "type": "symfony-messenger-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Doctrine Messenger Bridge", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-messenger/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T07:57:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "0b7be4e3f15ad53b46941bed1e59863245de8647" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b7be4e3f15ad53b46941bed1e59863245de8647", + "reference": "0b7be4e3f15ad53b46941bed1e59863245de8647", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^5.4|^6.0|^7.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-22T18:37:41+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T14:01:40+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/expression-language", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "91edabba1aff9da3326eee2a6e7666d1c1270751" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/91edabba1aff9da3326eee2a6e7666d1c1270751", + "reference": "91edabba1aff9da3326eee2a6e7666d1c1270751", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:27:22+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6dabf41c21957a2060f101bd7bf7e322989866bd", + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T07:59:31+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "c8d1b2a09bb820175d1f2f9a3cf30303200b10a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/c8d1b2a09bb820175d1f2f9a3cf30303200b10a7", + "reference": "c8d1b2a09bb820175d1f2f9a3cf30303200b10a7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T09:03:59+00:00" + }, + { + "name": "symfony/form", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "5753fe93e0e83dfea93b697d70e52bcb36a3f3e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/5753fe93e0e83dfea93b697d70e52bcb36a3f3e4", + "reference": "5753fe93e0e83dfea93b697d70e52bcb36a3f3e4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/options-resolver": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/doctrine-bridge": "<5.4.21|>=6,<6.2.7", + "symfony/error-handler": "<5.4", + "symfony/framework-bundle": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.3" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.2|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Form\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T11:58:14+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "a513e4e5d964599331811657c0e2f2c5fb21c293" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a513e4e5d964599331811657c0e2f2c5fb21c293", + "reference": "a513e4e5d964599331811657c0e2f2c5fb21c293", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.4.12|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.1|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4", + "symfony/polyfill-mbstring": "~1.0", + "symfony/routing": "^6.4|^7.0" + }, + "conflict": { + "doctrine/annotations": "<1.13.1", + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/asset": "<5.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.3", + "symfony/console": "<6.4.43|>=7.0", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<6.3", + "symfony/lock": "<5.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<6.3", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4", + "symfony/runtime": "<5.4.45|>=6.0,<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<5.4", + "symfony/security-csrf": "<5.4", + "symfony/serializer": "<6.4", + "symfony/stopwatch": "<5.4", + "symfony/translation": "<6.4", + "symfony/twig-bridge": "<5.4", + "symfony/twig-bundle": "<5.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "doctrine/annotations": "^1.13.1|^2", + "doctrine/persistence": "^1.3|^2|^3|^4", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.2|^7.0", + "symfony/console": "^6.4.43|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/dotenv": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-client": "^6.3|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/mailer": "^5.4|^6.0|^7.0", + "symfony/messenger": "^6.3|^7.0", + "symfony/mime": "^6.4.37|^7.4.9", + "symfony/notifier": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/scheduler": "^6.4.4|^7.0.4", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "symfony/semaphore": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/workflow": "^6.4|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/twig": "^2.10|^3.0.4|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T18:03:43+00:00" + }, + { + "name": "symfony/http-client", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "c064327ae0f64b7d5b27ed16fd8a7fa20350180c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/c064327ae0f64b7d5b27ed16fd8a7fa20350180c", + "reference": "c064327ae0f64b7d5b27ed16fd8a7fa20350180c", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T10:58:56+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "8c9d7b59c9315ad6801f3ac49234591d92f95634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/8c9d7b59c9315ad6801f3ac49234591d92f95634", + "reference": "8c9d7b59c9315ad6801f3ac49234591d92f95634", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T07:12:21+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "238e4681af2765043b4ed139843b3bded4220eb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/238e4681af2765043b4ed139843b3bded4220eb6", + "reference": "238e4681af2765043b4ed139843b3bded4220eb6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<5.4", + "symfony/cache": "<5.4", + "symfony/config": "<6.1", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<5.4", + "symfony/messenger": "<5.4", + "symfony/translation": "<5.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<5.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.3", + "twig/twig": "<2.13" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.2|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.5|^6.0.5|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.4|^7.0.4", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.4|^7.0", + "symfony/var-exporter": "^6.2|^7.0", + "twig/twig": "^2.13|^3.0.4|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T14:02:38+00:00" + }, + { + "name": "symfony/intl", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "d79905d256652f1353b26e1a59d89f835b2d31cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/d79905d256652f1353b26e1a59d89f835b2d31cf", + "reference": "d79905d256652f1353b26e1a59d89f835b2d31cf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T17:31:16+00:00" + }, + { + "name": "symfony/lock", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "94ea4a8415f40e5146d8559f74f632895505863b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/94ea4a8415f40e5146d8559f74f632895505863b", + "reference": "94ea4a8415f40e5146d8559f74f632895505863b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/cache": "<6.2" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-18T06:15:40+00:00" + }, + { + "name": "symfony/mailer", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "21cc391f6f6a20b8765125cabee0c6195b5b8f10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/21cc391f6f6a20b8765125cabee0c6195b5b8f10", + "reference": "21cc391f6f6a20b8765125cabee0c6195b5b8f10", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.1", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/mime": "^6.2|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/messenger": "<6.2", + "symfony/mime": "<6.2", + "symfony/twig-bridge": "<6.2.1" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/messenger": "^6.2|^7.0", + "symfony/twig-bridge": "^6.2|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-14T23:26:17+00:00" + }, + { + "name": "symfony/messenger", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "19a98a82b4452f820034fb88815dac3783975d17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/19a98a82b4452f820034fb88815dac3783975d17", + "reference": "19a98a82b4452f820034fb88815dac3783975d17", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.3|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.3", + "symfony/event-dispatcher": "<5.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/serializer": "<5.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^6.3|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/validator": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-21T15:42:22+00:00" + }, + { + "name": "symfony/mime", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "e700a9819829e1395ae95b508c6355053b5644fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/e700a9819829e1395ae95b508c6355053b5644fe", + "reference": "e700a9819829e1395ae95b508c6355053b5644fe", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<5.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.4|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.44|^7.4.17" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-04T10:11:03+00:00" + }, + { + "name": "symfony/monolog-bridge", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "d3f5dd9133dc5723c7ee77e37b3db07e6934576d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/d3f5dd9133dc5723c7ee77e37b3db07e6934576d", + "reference": "d3f5dd9133dc5723c7ee77e37b3db07e6934576d", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1|^2|^3", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/http-foundation": "<5.4", + "symfony/security-core": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/mailer": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/notifier": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T01:18:12+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.11.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "d87468010570b2ec766152184918ee8d267c7411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411", + "reference": "d87468010570b2ec766152184918ee8d267c7411", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=8.1", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/monolog-bridge": "^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "symfony/console": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.3.3", + "symfony/yaml": "^6.4 || ^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-02T18:23:01+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v6.4.30", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", + "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v6.4.30" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T13:06:53+00:00" + }, + { + "name": "symfony/password-hasher", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "fbdfa5a2ca218ec8bb9029517426df2d780bdba9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/fbdfa5a2ca218ec8bb9029517426df2d780bdba9", + "reference": "fbdfa5a2ca218ec8bb9029517426df2d780bdba9", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/security-core": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v6.4.32" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T21:24:53+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "254724d95fe01b679cd97d88786e9623ee00bfb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/254724d95fe01b679cd97d88786e9623ee00bfb7", + "reference": "254724d95fe01b679cd97d88786e9623ee00bfb7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-iconv": "*" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T06:25:14+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "d13cf82f40af144e5e5a867dc54ec3c7b4544b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/d13cf82f40af144e5e5a867dc54ec3c7b4544b2e", + "reference": "d13cf82f40af144e5e5a867dc54ec3c7b4544b2e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:35:55+00:00" + }, + { + "name": "symfony/property-access", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4" + }, + "require-dev": { + "symfony/cache": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T11:04:25+00:00" + }, + { + "name": "symfony/property-info", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4789c9a2650f9285da6db0363ef40e5a21b42ce9", + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<5.4", + "symfony/dependency-injection": "<5.4|>=6.0,<6.4", + "symfony/serializer": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-03T19:31:46+00:00" + }, + { + "name": "symfony/proxy-manager-bridge", + "version": "v6.4.28", + "source": { + "type": "git", + "url": "https://github.com/symfony/proxy-manager-bridge.git", + "reference": "9ecac7f98ad685d474394dbd06dab29bab4e18a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/proxy-manager-bridge/zipball/9ecac7f98ad685d474394dbd06dab29bab4e18a6", + "reference": "9ecac7f98ad685d474394dbd06dab29bab4e18a6", + "shasum": "" + }, + "require": { + "friendsofphp/proxy-manager-lts": "^1.0.2", + "php": ">=8.1", + "symfony/dependency-injection": "^6.3|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/config": "^6.1|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\ProxyManager\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for ProxyManager with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/proxy-manager-bridge/tree/v6.4.28" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-02T18:11:54+00:00" + }, + { + "name": "symfony/routing", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "641a94df9ad73671abd86bc4303aa301ca555b2b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/641a94df9ad73671abd86bc4303aa301ca555b2b", + "reference": "641a94df9ad73671abd86bc4303aa301ca555b2b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T18:59:50+00:00" + }, + { + "name": "symfony/security-bundle", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "f3ffaf6d5e6fad7f50c6ae5c021003ba6e374622" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f3ffaf6d5e6fad7f50c6ae5c021003ba6e374622", + "reference": "f3ffaf6d5e6fad7f50c6ae5c021003ba6e374622", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.1", + "symfony/clock": "^6.3|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.4.11|^7.1.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.2|^7.0", + "symfony/http-kernel": "^6.2", + "symfony/password-hasher": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.2|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/security-http": "^6.3.6|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/browser-kit": "<5.4", + "symfony/console": "<5.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-client": "<5.4", + "symfony/ldap": "<5.4", + "symfony/serializer": "<6.4", + "symfony/twig-bundle": "<5.4", + "symfony/validator": "<6.4" + }, + "require-dev": { + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/twig-bridge": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/twig": "^2.13|^3.0.4|^4.0", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1", + "web-token/jwt-signature-algorithm-eddsa": "^3.1", + "web-token/jwt-signature-algorithm-hmac": "^3.1", + "web-token/jwt-signature-algorithm-none": "^3.1", + "web-token/jwt-signature-algorithm-rsa": "^3.1" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SecurityBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-bundle/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T08:57:57+00:00" + }, + { + "name": "symfony/security-core", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "8479fccf23be745d7a05beb0d31f989ea515529a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8479fccf23be745d7a05beb0d31f989ea515529a", + "reference": "8479fccf23be745d7a05beb0d31f989ea515529a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/event-dispatcher": "<5.4", + "symfony/http-foundation": "<5.4", + "symfony/ldap": "<5.4", + "symfony/security-guard": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<5.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/validator": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Core\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-21T20:17:28+00:00" + }, + { + "name": "symfony/security-csrf", + "version": "v6.4.31", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-csrf.git", + "reference": "52f62836fcb19cd351ef3a2aa9cf61a489e8990f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/52f62836fcb19cd351ef3a2aa9cf61a489e8990f", + "reference": "52f62836fcb19cd351ef3a2aa9cf61a489e8990f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/security-core": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/http-foundation": "<5.4" + }, + "require-dev": { + "symfony/http-foundation": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Csrf\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - CSRF Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-csrf/tree/v6.4.31" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-17T22:32:13+00:00" + }, + { + "name": "symfony/security-http", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "44c7c1afb0537eedfe4670079eaf89ee179095b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/44c7c1afb0537eedfe4670079eaf89ee179095b1", + "reference": "44c7c1afb0537eedfe4670079eaf89ee179095b1", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.2|^7.0", + "symfony/http-kernel": "^6.3|^7.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/clock": "<6.3", + "symfony/event-dispatcher": "<5.4.9|>=6,<6.0.9", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<5.4", + "symfony/security-csrf": "<5.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.3|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-15T16:35:01+00:00" + }, + { + "name": "symfony/serializer", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "67e177a3fe8cae15decfb004563f9b4988296be3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/67e177a3fe8cae15decfb004563f9b4988296be3", + "reference": "67e177a3fe8cae15decfb004563f9b4988296be3", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<6.4.43", + "symfony/uid": "<5.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.26|^6.3|^7.0", + "symfony/property-info": "^6.4.43|^7.4.15", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T08:44:55+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/stimulus-bundle", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stimulus-bundle.git", + "reference": "377a3d1ec5834631a7db53bd275276ff3c5b49df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/377a3d1ec5834631a7db53bd275276ff3c5b49df", + "reference": "377a3d1ec5834631a7db53bd275276ff3c5b49df", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/config": "^5.4|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.0|^3.0", + "symfony/finder": "^5.4|^6.0|^7.0|^8.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^2.15.3|^3.8" + }, + "require-dev": { + "symfony/asset-mapper": "^6.3|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "zenstruck/browser": "^1.4" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\UX\\StimulusBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Integration with your Symfony app & Stimulus!", + "keywords": [ + "symfony-ux" + ], + "support": { + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T04:31:36+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/string", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-11T09:03:57+00:00" + }, + { + "name": "symfony/translation", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "fa2235501e2cf6b1d38ab42954b248a5e93c8e89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/fa2235501e2cf6b1d38ab42954b248a5e93c8e89", + "reference": "fa2235501e2cf6b1d38ab42954b248a5e93c8e89", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.18|^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T19:22:13+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "5b38d19f327f32507f96c9316aef90e7ad4962ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/5b38d19f327f32507f96c9316aef90e7ad4962ac", + "reference": "5b38d19f327f32507f96c9316aef90e7ad4962ac", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^2.13|^3.0.4|^4.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/console": "<5.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4", + "symfony/http-foundation": "<5.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9", + "symfony/serializer": "<6.4", + "symfony/translation": "<5.4", + "symfony/workflow": "<5.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/asset-mapper": "^6.3|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/mime": "^6.4.37|^7.4.9", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/security-http": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.44|^7.4.17", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^6.1|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/workflow": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/cssinliner-extra": "^2.12|^3", + "twig/inky-extra": "^2.12|^3", + "twig/markdown-extra": "^2.12|^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-14T10:25:06+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v6.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "d5996b3226a441ff558751646aa7c0775b90e2ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/d5996b3226a441ff558751646aa7c0775b90e2ee", + "reference": "d5996b3226a441ff558751646aa7c0775b90e2ee", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.1", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.2", + "symfony/twig-bridge": "^6.4", + "twig/twig": "^2.13|^3.0.4|^4.0" + }, + "conflict": { + "symfony/framework-bundle": "<5.4", + "symfony/translation": "<5.4" + }, + "require-dev": { + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v6.4.46" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-11T10:34:36+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.20", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/bbaaf3366400f5c85f66800b955c77b46e8abad2", + "reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.20" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T12:19:37+00:00" + }, + { + "name": "symfony/uid", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2266f787b76bbbc339f4fdb40111ca461782f282" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2266f787b76bbbc339f4fdb40111ca461782f282", + "reference": "2266f787b76bbbc339f4fdb40111ca461782f282", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T16:05:37+00:00" + }, + { + "name": "symfony/ux-autocomplete", + "version": "v2.36.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-autocomplete.git", + "reference": "832f66056959aba68faef3a6cabc8d8ff606b2e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/832f66056959aba68faef3a6cabc8d8ff606b2e4", + "reference": "832f66056959aba68faef3a6cabc8d8ff606b2e4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^6.3|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.3|^7.0|^8.0", + "symfony/http-kernel": "^6.3|^7.0|^8.0", + "symfony/property-access": "^6.3|^7.0|^8.0" + }, + "conflict": { + "doctrine/orm": "2.9.0 || 2.9.1" + }, + "require-dev": { + "doctrine/collections": "^1.6.8|^2.0", + "doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0", + "doctrine/orm": "^2.9.4|^3.0", + "fakerphp/faker": "^1.22", + "mtdowling/jmespath.php": "^2.6", + "symfony/form": "^6.3|^7.0|^8.0", + "symfony/framework-bundle": "^6.3|^7.0|^8.0", + "symfony/maker-bundle": "^1.40", + "symfony/options-resolver": "^6.3|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.3|^7.0|^8.0", + "symfony/process": "^6.3|^7.0|^8.0", + "symfony/security-bundle": "^6.3|^7.0|^8.0", + "symfony/twig-bundle": "^6.3|^7.0|^8.0", + "symfony/uid": "^6.3|^7.0|^8.0", + "twig/twig": "^2.14.7|^3.0.4", + "zenstruck/browser": "^1.1", + "zenstruck/foundry": "^2.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Autocomplete\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "JavaScript Autocomplete functionality for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "symfony-ux" + ], + "support": { + "source": "https://github.com/symfony/ux-autocomplete/tree/v2.36.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-08T13:49:33+00:00" + }, + { + "name": "symfony/ux-icons", + "version": "v2.36.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-icons.git", + "reference": "567f33ddffc25504788abc61977565381e424ada" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-icons/zipball/567f33ddffc25504788abc61977565381e424ada", + "reference": "567f33ddffc25504788abc61977565381e424ada", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/flex": "<1.13", + "symfony/ux-twig-component": "<2.21" + }, + "require-dev": { + "psr/log": "^2|^3", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.3|^7.0|^8.0", + "symfony/ux-twig-component": "^2.14|^3.0", + "zenstruck/console-test": "^1.5" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Icons\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + }, + { + "name": "Simon André", + "email": "smn.andre@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Renders local and remote SVG icons in your Twig templates.", + "homepage": "https://symfony.com", + "keywords": [ + "icons", + "svg", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-icons/tree/v2.36.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T05:43:46+00:00" + }, + { + "name": "symfony/ux-live-component", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-live-component.git", + "reference": "a3fb5f24ff430db6a78019294086c16f70c86192" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/a3fb5f24ff430db6a78019294086c16f70c86192", + "reference": "a3fb5f24ff430db6a78019294086c16f70c86192", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/property-access": "^5.4.5|^6.0|^7.0|^8.0", + "symfony/property-info": "^5.4|^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9|^3.0", + "symfony/ux-twig-component": "^2.33.0|^3.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0", + "symfony/property-info": "~7.0.0", + "symfony/type-info": "<7.2" + }, + "require-dev": { + "doctrine/annotations": "^1.0|^2.0", + "doctrine/collections": "^1.6.8|^2.0", + "doctrine/doctrine-bundle": "^2.4.3|^3.0|^4.0", + "doctrine/orm": "^2.9.4|^3.0", + "doctrine/persistence": "^2.5.2|^3.0|^4.0", + "phpdocumentor/reflection-docblock": "^5.6.2", + "symfony/config": "^6.3|^7.0|^8.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", + "symfony/form": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.1|^7.0|^8.0", + "symfony/http-kernel": "^6.1|^7.0|^8.0", + "symfony/options-resolver": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.1|^7.0|^8.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/serializer": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/uid": "^5.4|^6.0|^7.0|^8.0", + "symfony/validator": "^5.4|^6.0|^7.0|^8.0", + "zenstruck/browser": "^1.2.0", + "zenstruck/foundry": "^2.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\LiveComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Live components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-live-component/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T06:59:30+00:00" + }, + { + "name": "symfony/ux-twig-component", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.15|^2.3.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-03T05:13:59+00:00" + }, + { + "name": "symfony/validator", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "022729aef78cefd4ac8b745f557abd57dc910204" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/022729aef78cefd4ac8b745f557abd57dc910204", + "reference": "022729aef78cefd4ac8b745f557abd57dc910204", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.13", + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<5.4", + "symfony/expression-language": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/intl": "<5.4", + "symfony/property-info": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.13|^2", + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T11:58:14+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86a9b2f1bd81c9780a809632238ab6ba10292166", + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.3|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", + "twig/twig": "^2.13|^3.0.4|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:39+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/a4e458999ee554a667cd19227f0b97a751bd61e6", + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T09:39:49+00:00" + }, + { + "name": "symfony/web-link", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-link.git", + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-link/zipball/636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/link": "^1.1|^2.0" + }, + "conflict": { + "symfony/http-kernel": "<5.4" + }, + "provide": { + "psr/link-implementation": "1.0|2.0" + }, + "require-dev": { + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\WebLink\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages links between resources", + "homepage": "https://symfony.com", + "keywords": [ + "dns-prefetch", + "http", + "http2", + "link", + "performance", + "prefetch", + "preload", + "prerender", + "psr13", + "push" + ], + "support": { + "source": "https://github.com/symfony/web-link/tree/v6.4.32" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T13:45:34+00:00" + }, + { + "name": "symfony/webpack-encore-bundle", + "version": "2.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/webpack-encore-bundle.git", + "reference": "0cbc3485f127cd9f85e395f8177607ef00535634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634", + "reference": "0cbc3485f127cd9f85e395f8177607ef00535634", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/asset": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/config": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/service-contracts": "^1.1.9 || ^2.1.3 || ^3.0" + }, + "require-dev": { + "symfony/framework-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/http-client": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.2 || ^7.0 || ^8.0", + "symfony/web-link": "^5.4 || ^6.2 || ^7.0 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/webpack-encore", + "name": "symfony/webpack-encore" + } + }, + "autoload": { + "psr-4": { + "Symfony\\WebpackEncoreBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Integration of your Symfony app with Webpack Encore", + "support": { + "issues": "https://github.com/symfony/webpack-encore-bundle/issues", + "source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-17T12:45:02+00:00" + }, + { + "name": "symfony/workflow", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/workflow.git", + "reference": "f44fcf2b5b712a9a9c734a2d076007e0afec28d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/workflow/zipball/f44fcf2b5b712a9a9c734a2d076007e0afec28d4", + "reference": "f44fcf2b5b712a9a9c734a2d076007e0afec28d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/event-dispatcher": "<5.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/validator": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Workflow\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools for managing a workflow or finite state machine", + "homepage": "https://symfony.com", + "keywords": [ + "petrinet", + "place", + "state", + "statemachine", + "transition", + "workflow" + ], + "support": { + "source": "https://github.com/symfony/workflow/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-21T16:55:19+00:00" + }, + { + "name": "symfony/yaml", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "928e3ef5d46756da90cc2f54c3bd4a21bc18ceff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/928e3ef5d46756da90cc2f54c3bd4a21bc18ceff", + "reference": "928e3ef5d46756da90cc2f54c3bd4a21bc18ceff", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-26T06:15:25+00:00" + }, + { + "name": "symfonycasts/dynamic-forms", + "version": "v0.1.3", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/dynamic-forms.git", + "reference": "4c86c48f18a707e451c4dfffe87f3710b2052be6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/dynamic-forms/zipball/4c86c48f18a707e451c4dfffe87f3710b2052be6", + "reference": "4c86c48f18a707e451c4dfffe87f3710b2052be6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/form": "^5.4|^6.3|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "symfony/framework-bundle": "^6.3|^7.0", + "symfony/options-resolver": "^5.4|^6.3|^7.0", + "symfony/phpunit-bridge": "^5.4.32|^6.3.9|^7.0", + "symfony/twig-bundle": "^5.4|^6.3|^7.0", + "twig/twig": "^2.15|^3.0", + "zenstruck/browser": "^1.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfonycasts\\DynamicForms\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" + } + ], + "description": "Add dynamic/dependent fields to Symfony forms", + "keywords": [ + "Forms", + "symfony" + ], + "support": { + "issues": "https://github.com/SymfonyCasts/dynamic-forms/issues", + "source": "https://github.com/SymfonyCasts/dynamic-forms/tree/v0.1.3" + }, + "time": "2024-10-22T16:59:02+00:00" + }, + { + "name": "twig/extra-bundle", + "version": "v3.29.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569", + "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.2|^4.0" + }, + "require-dev": { + "league/commonmark": "^2.7", + "symfony/phpunit-bridge": "^6.4|^7.0", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-09-11T08:59:50+00:00" + }, + { + "name": "twig/intl-extra", + "version": "v3.30.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/211c603b3f7d5a8f6c6543a02fe9f82c09b18025", + "reference": "211c603b3f7d5a8f6c6543a02fe9f82c09b18025", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/intl": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.13|^4.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", + "keywords": [ + "intl", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/intl-extra/tree/v3.30.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-09-25T05:50:11+00:00" + }, + { + "name": "twig/string-extra", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/string-extra.git", + "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/string-extra/zipball/6ec8f2e8ca9b2193221a02cb599dc92c36384368", + "reference": "6ec8f2e8ca9b2193221a02cb599dc92c36384368", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/string": "^5.4|^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^1.1|^2|^3", + "twig/twig": "^3.13|^4.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Symfony String", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "string", + "twig", + "unicode" + ], + "support": { + "source": "https://github.com/twigphp/string-extra/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2025-12-02T14:45:16+00:00" + }, + { + "name": "twig/twig", + "version": "v3.30.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.30.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-09-25T13:20:01+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" + }, + { + "name": "willdurand/negotiation", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Negotiation\\": "src/Negotiation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", + "keywords": [ + "accept", + "content", + "format", + "header", + "negotiation" + ], + "support": { + "issues": "https://github.com/willdurand/Negotiation/issues", + "source": "https://github.com/willdurand/Negotiation/tree/3.1.0" + }, + "time": "2022-01-30T20:08:53+00:00" + } + ], + "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-19T17:59:20+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-26T17:53:54+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-16T16:54:01+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.7" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-26T14:50:43+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-08-22T18:13:20+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "behat/behat", + "version": "v3.20.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "edb265a32329d514e3a5ec44924646e0e02d95ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/edb265a32329d514e3a5ec44924646e0e02d95ee", + "reference": "edb265a32329d514e3a5ec44924646e0e02d95ee", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.12.0", + "behat/transliterator": "^1.5", + "composer-runtime-api": "^2.2", + "composer/xdebug-handler": "^3.0", + "ext-mbstring": "*", + "nikic/php-parser": "^5.0", + "php": "8.1.* || 8.2.* || 8.3.* || 8.4.* ", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/translation": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.68", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.6", + "sebastian/diff": "^4.0", + "symfony/polyfill-php84": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Hook\\": "src/Behat/Hook/", + "Behat\\Step\\": "src/Behat/Step/", + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Config\\": "src/Behat/Config/", + "Behat\\Testwork\\": "src/Behat/Testwork/", + "Behat\\Transformation\\": "src/Behat/Transformation/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.20.0" + }, + "time": "2025-04-02T14:51:45+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.18.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "496248b29d9d7848311a263c2d17c1860e4c7b6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/496248b29d9d7848311a263c2d17c1860e4c7b6c", + "reference": "496248b29d9d7848311a263c2d17c1860e4c7b6c", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": ">=8.1 <8.6" + }, + "require-dev": { + "cucumber/gherkin-monorepo": "dev-gherkin-v42.0.1", + "friendsofphp/php-cs-fixer": "^3.77", + "mikey179/vfsstream": "^1.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2", + "phpunit/phpunit": "^10.5", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Gherkin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "https://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.18.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-09-25T21:26:10+00:00" + }, + { + "name": "behat/mink-selenium2-driver", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/minkphp/MinkSelenium2Driver.git", + "reference": "4ca4083f305de7dff4434ac402dc4e3f39c0866a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/minkphp/MinkSelenium2Driver/zipball/4ca4083f305de7dff4434ac402dc4e3f39c0866a", + "reference": "4ca4083f305de7dff4434ac402dc4e3f39c0866a", + "shasum": "" + }, + "require": { + "behat/mink": "^1.11@dev", + "ext-json": "*", + "instaclick/php-webdriver": "^1.4.14", + "php": ">=7.2" + }, + "require-dev": { + "mink/driver-testsuite": "dev-master", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^8.5.22 || ^9.5.11", + "symfony/error-handler": "^4.4 || ^5.0 || ^6.0 || ^7.0" + }, + "type": "mink-driver", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Mink\\Driver\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pete Otaqui", + "email": "pete@otaqui.com", + "homepage": "https://github.com/pete-otaqui" + }, + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Selenium2 (WebDriver) driver for Mink framework", + "homepage": "https://mink.behat.org/", + "keywords": [ + "ajax", + "browser", + "javascript", + "selenium", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/minkphp/MinkSelenium2Driver/issues", + "source": "https://github.com/minkphp/MinkSelenium2Driver/tree/v1.7.0" + }, + "time": "2023-12-09T11:58:45+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "dmore/behat-chrome-extension", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://gitlab.com/behat-chrome/behat-chrome-extension.git", + "reference": "888e91f52b3ffd19afe61cea3d5edebb0a4d43a7" + }, + "dist": { + "type": "zip", + "url": "https://gitlab.com/api/v4/projects/behat-chrome%2Fbehat-chrome-extension/repository/archive.zip?sha=888e91f52b3ffd19afe61cea3d5edebb0a4d43a7", + "reference": "888e91f52b3ffd19afe61cea3d5edebb0a4d43a7", + "shasum": "" + }, + "require": { + "behat/behat": "^3.0.4", + "dmore/chrome-mink-driver": "^2.4.1", + "friends-of-behat/mink-extension": "^2.0", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "DMore\\ChromeExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dorian More", + "email": "doriancmore@gmail.com" + } + ], + "description": "Behat extension for controlling Chrome without Selenium", + "homepage": "https://gitlab.com/behat-chrome/chrome-mink-driver", + "keywords": [ + "Behat", + "chrome", + "driver", + "headless" + ], + "support": { + "issues": "https://gitlab.com/behat-chrome/behat-chrome-extension/-/issues", + "source": "https://gitlab.com/behat-chrome/behat-chrome-extension/-/tree/1.4.0" + }, + "time": "2022-04-10T04:25:11+00:00" + }, + { + "name": "dmore/chrome-mink-driver", + "version": "2.9.3", + "source": { + "type": "git", + "url": "https://gitlab.com/behat-chrome/chrome-mink-driver.git", + "reference": "4dc18d3b4668e749ab7bef5a6796c13711c93e61" + }, + "dist": { + "type": "zip", + "url": "https://gitlab.com/api/v4/projects/behat-chrome%2Fchrome-mink-driver/repository/archive.zip?sha=4dc18d3b4668e749ab7bef5a6796c13711c93e61", + "reference": "4dc18d3b4668e749ab7bef5a6796c13711c93e61", + "shasum": "" + }, + "require": { + "behat/mink": "^1.9", + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "phrity/websocket": "^1.7.0" + }, + "require-dev": { + "mink/driver-testsuite": "dev-master", + "phpunit/phpunit": "^8.5.22 || ^9.5.11", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "DMore\\ChromeDriver\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dorian More", + "email": "doriancmore@gmail.com" + } + ], + "description": "Mink driver for controlling chrome without selenium", + "homepage": "https://gitlab.com/behat-chrome/chrome-mink-driver", + "support": { + "issues": "https://gitlab.com/behat-chrome/chrome-mink-driver/-/issues", + "source": "https://gitlab.com/behat-chrome/chrome-mink-driver/-/tree/2.9.3" + }, + "time": "2024-05-17T12:26:55+00:00" + }, + { + "name": "friends-of-behat/mink", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/Mink.git", + "reference": "8aa0dc57999cb12736b80b379e22187d7f18e8a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/Mink/zipball/8aa0dc57999cb12736b80b379e22187d7f18e8a9", + "reference": "8aa0dc57999cb12736b80b379e22187d7f18e8a9", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/css-selector": "^4.4|^5.0|^6.0|^7.0" + }, + "replace": { + "behat/mink": "self.version" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0" + }, + "suggest": { + "behat/mink-browserkit-driver": "extremely fast headless driver for Symfony\\Kernel-based apps (Sf2, Silex)", + "behat/mink-goutte-driver": "fast headless driver for any app without JS emulation", + "behat/mink-selenium2-driver": "slow, but JS-enabled driver for any app (requires Selenium2)", + "behat/mink-zombie-driver": "fast and JS-enabled headless driver for any app (requires node.js)", + "dmore/chrome-mink-driver": "fast and JS-enabled driver for any app (requires chromium or google chrome)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Mink\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Browser controller/emulator abstraction for PHP", + "homepage": "http://mink.behat.org/", + "keywords": [ + "browser", + "testing", + "web" + ], + "support": { + "source": "https://github.com/FriendsOfBehat/Mink/tree/v1.11.0" + }, + "abandoned": "behat/mink", + "time": "2024-02-06T13:17:10+00:00" + }, + { + "name": "friends-of-behat/mink-browserkit-driver", + "version": "v1.6.2", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/MinkBrowserKitDriver.git", + "reference": "4f7d58037f8aa5f3aa17308cb6341b029859ea65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/MinkBrowserKitDriver/zipball/4f7d58037f8aa5f3aa17308cb6341b029859ea65", + "reference": "4f7d58037f8aa5f3aa17308cb6341b029859ea65", + "shasum": "" + }, + "require": { + "behat/mink": "^1.7", + "php": "^7.4|^8.0", + "symfony/browser-kit": "^4.4|^5.0|^6.0|^7.0", + "symfony/dom-crawler": "^4.4|^5.0|^6.0|^7.0" + }, + "replace": { + "behat/mink-browserkit-driver": "self.version" + }, + "require-dev": { + "friends-of-behat/mink-driver-testsuite": "dev-master", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0" + }, + "type": "mink-driver", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Mink\\Driver\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Symfony2 BrowserKit driver for Mink framework", + "homepage": "http://mink.behat.org/", + "keywords": [ + "Mink", + "Symfony2", + "browser", + "testing" + ], + "support": { + "source": "https://github.com/FriendsOfBehat/MinkBrowserKitDriver/tree/v1.6.2" + }, + "abandoned": "behat/mink-browserkit-driver", + "time": "2024-02-06T13:25:07+00:00" + }, + { + "name": "friends-of-behat/mink-debug-extension", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/MinkDebugExtension.git", + "reference": "270e5aa5aef5358d81569a9a16eb2b3258314f9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/MinkDebugExtension/zipball/270e5aa5aef5358d81569a9a16eb2b3258314f9a", + "reference": "270e5aa5aef5358d81569a9a16eb2b3258314f9a", + "shasum": "" + }, + "require": { + "behat/behat": "^3.5", + "behat/mink-extension": "^2.3", + "php": ">=7.4" + }, + "require-dev": { + "behat/mink-goutte-driver": "^1.2", + "behat/mink-selenium2-driver": "^1.4", + "dmore/behat-chrome-extension": "^1.3", + "dmore/chrome-mink-driver": "^2.7", + "symfony/process": "^4.4 || ^5.2" + }, + "type": "behat-extension", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "FriendsOfBehat\\MinkDebugExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "description": "Debug extension for Behat", + "homepage": "https://github.com/FriendsOfBehat/MinkDebugExtension", + "keywords": [ + "Behat", + "Mink", + "debug", + "logging" + ], + "support": { + "issues": "https://github.com/FriendsOfBehat/MinkDebugExtension/issues", + "source": "https://github.com/FriendsOfBehat/MinkDebugExtension/tree/v2.1.0" + }, + "time": "2021-12-13T08:52:43+00:00" + }, + { + "name": "friends-of-behat/mink-extension", + "version": "v2.7.5", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/MinkExtension.git", + "reference": "854336030e11983f580f49faad1b49a1238f9846" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/MinkExtension/zipball/854336030e11983f580f49faad1b49a1238f9846", + "reference": "854336030e11983f580f49faad1b49a1238f9846", + "shasum": "" + }, + "require": { + "behat/behat": "^3.0.5", + "behat/mink": "^1.5", + "php": ">=7.4", + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "behat/mink-extension": "self.version" + }, + "require-dev": { + "behat/mink-goutte-driver": "^1.1 || ^2.0", + "phpspec/phpspec": "^6.0 || ^7.0 || 7.1.x-dev" + }, + "type": "behat-extension", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\MinkExtension": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com" + }, + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + } + ], + "description": "Mink extension for Behat", + "homepage": "http://extensions.behat.org/mink", + "keywords": [ + "browser", + "gui", + "test", + "web" + ], + "support": { + "issues": "https://github.com/FriendsOfBehat/MinkExtension/issues", + "source": "https://github.com/FriendsOfBehat/MinkExtension/tree/v2.7.5" + }, + "time": "2024-01-11T09:12:02+00:00" + }, + { + "name": "friends-of-behat/page-object-extension", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/PageObjectExtension.git", + "reference": "2e65b0bd7cca6ff2085b0fa885dc13dc57b18d4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/PageObjectExtension/zipball/2e65b0bd7cca6ff2085b0fa885dc13dc57b18d4b", + "reference": "2e65b0bd7cca6ff2085b0fa885dc13dc57b18d4b", + "shasum": "" + }, + "require": { + "behat/mink": "^1.7", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/routing": "^3.4 || ^4.4 || ^5.1" + }, + "suggest": { + "symfony/routing": "Allow better support for PageObject pattern in Symfony applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.3-dev" + } + }, + "autoload": { + "psr-4": { + "FriendsOfBehat\\PageObjectExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Łukasz Chruściel", + "email": "lchrusciel@gmail.com" + }, + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "http://kamil.kokot.me" + }, + { + "name": "Mateusz Zalewski", + "email": "mateusz.p.zalewski@gmail.com", + "homepage": "http://mpzalewski.com.pl" + } + ], + "description": "Provides default classes for Page object pattern in Behat", + "support": { + "issues": "https://github.com/FriendsOfBehat/PageObjectExtension/issues", + "source": "https://github.com/FriendsOfBehat/PageObjectExtension/tree/v0.3.2" + }, + "time": "2020-11-05T20:37:07+00:00" + }, + { + "name": "friends-of-behat/suite-settings-extension", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/SuiteSettingsExtension.git", + "reference": "7a4e44e232622d20680582f305b33846f0617823" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/SuiteSettingsExtension/zipball/7a4e44e232622d20680582f305b33846f0617823", + "reference": "7a4e44e232622d20680582f305b33846f0617823", + "shasum": "" + }, + "require": { + "behat/behat": "^3.8", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friends-of-behat/test-context": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "FriendsOfBehat\\SuiteSettingsExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "description": "Allows to overwrite suites' default settings.", + "support": { + "issues": "https://github.com/FriendsOfBehat/SuiteSettingsExtension/issues", + "source": "https://github.com/FriendsOfBehat/SuiteSettingsExtension/tree/v1.1.0" + }, + "time": "2021-02-05T14:35:02+00:00" + }, + { + "name": "friends-of-behat/symfony-extension", + "version": "v2.6.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/SymfonyExtension.git", + "reference": "dfb1c9c96cc0fb7c8e1caa060695426a12e1efbd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/SymfonyExtension/zipball/dfb1c9c96cc0fb7c8e1caa060695426a12e1efbd", + "reference": "dfb1c9c96cc0fb7c8e1caa060695426a12e1efbd", + "shasum": "" + }, + "require": { + "behat/behat": "^3.6.1", + "php": "^8.1", + "symfony/dependency-injection": "^6.2 || ^7.0", + "symfony/http-kernel": "^6.2 || ^7.0" + }, + "require-dev": { + "behat/mink": "^1.9", + "behat/mink-browserkit-driver": "^2.0", + "behat/mink-selenium2-driver": "^1.3", + "friends-of-behat/mink-extension": "^2.5", + "friends-of-behat/page-object-extension": "^0.3.2", + "friends-of-behat/service-container-extension": "^1.1", + "sylius-labs/coding-standard": ">=4.1.1, <=4.2.1", + "symfony/browser-kit": "^6.2 || ^7.0", + "symfony/framework-bundle": "^6.2 || ^7.0", + "symfony/process": "^6.2 || ^7.0", + "symfony/yaml": "^6.2 || ^7.0", + "vimeo/psalm": "4.30.0" + }, + "suggest": { + "behat/mink": "^1.9", + "behat/mink-browserkit-driver": "^2.0", + "friends-of-behat/mink-extension": "^2.5" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + } + }, + "autoload": { + "psr-4": { + "FriendsOfBehat\\SymfonyExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "description": "Integrates Behat with Symfony.", + "support": { + "issues": "https://github.com/FriendsOfBehat/SymfonyExtension/issues", + "source": "https://github.com/FriendsOfBehat/SymfonyExtension/tree/v2.6.0" + }, + "time": "2024-07-03T15:49:43+00:00" + }, + { + "name": "friends-of-behat/variadic-extension", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/VariadicExtension.git", + "reference": "892929189fd4deac9a920e64bb124231be624cdc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/VariadicExtension/zipball/892929189fd4deac9a920e64bb124231be624cdc", + "reference": "892929189fd4deac9a920e64bb124231be624cdc", + "shasum": "" + }, + "require": { + "behat/behat": "^3.8", + "php": "^8.1", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "friends-of-behat/test-context": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5-dev" + } + }, + "autoload": { + "psr-4": { + "FriendsOfBehat\\VariadicExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Łukasz Chruściel", + "email": "lchrusciel@gmail.com" + } + ], + "description": "Variadic support for behat context arguments", + "support": { + "issues": "https://github.com/FriendsOfBehat/VariadicExtension/issues", + "source": "https://github.com/FriendsOfBehat/VariadicExtension/tree/v1.6.0" + }, + "time": "2024-01-30T11:13:11+00:00" + }, + { + "name": "friendsoftwig/twigcs", + "version": "v6.5.0", + "source": { + "type": "git", + "url": "https://github.com/friendsoftwig/twigcs.git", + "reference": "540fd6b80a4bcd1eb906b49464ce4513ee24fd5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/friendsoftwig/twigcs/zipball/540fd6b80a4bcd1eb906b49464ce4513ee24fd5b", + "reference": "540fd6b80a4bcd1eb906b49464ce4513ee24fd5b", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "symfony/console": "^4.4 || ^5.3 || ^6.0 || ^7.0", + "symfony/filesystem": "^4.4 || ^5.3 || ^6.0 || ^7.0", + "symfony/finder": "^4.4 || ^5.3 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.19", + "symfony/phpunit-bridge": "^7.2.0" + }, + "bin": [ + "bin/twigcs" + ], + "type": "library", + "autoload": { + "psr-4": { + "FriendsOfTwig\\Twigcs\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tristan Maindron", + "email": "tmaindron@gmail.com" + } + ], + "description": "Checkstyle automation for Twig", + "support": { + "issues": "https://github.com/friendsoftwig/twigcs/issues", + "source": "https://github.com/friendsoftwig/twigcs/tree/v6.5.0" + }, + "time": "2025-11-28T15:45:09+00:00" + }, + { + "name": "gitonomy/gitlib", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/gitonomy/gitlib.git", + "reference": "c1476cb0fd317fc512971e926b40ebcdb666e263" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gitonomy/gitlib/zipball/c1476cb0fd317fc512971e926b40ebcdb666e263", + "reference": "c1476cb0fd317fc512971e926b40ebcdb666e263", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "php": "^8.0", + "symfony/polyfill-mbstring": "^1.7", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.20 || ^9.5.9", + "psr/log": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gitonomy\\Git\\": "src/Gitonomy/Git/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Julien Didier", + "email": "genzo.wm@gmail.com", + "homepage": "https://github.com/juliendidier" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info", + "homepage": "https://github.com/lyrixx" + }, + { + "name": "Alexandre Salomé", + "email": "alexandre.salome@gmail.com", + "homepage": "https://github.com/alexandresalome" + } + ], + "description": "Library for accessing git", + "support": { + "issues": "https://github.com/gitonomy/gitlib/issues", + "source": "https://github.com/gitonomy/gitlib/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/gitonomy/gitlib", + "type": "tidelift" + } + ], + "time": "2025-12-09T20:06:56+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "instaclick/php-webdriver", + "version": "1.4.20", + "source": { + "type": "git", + "url": "https://github.com/instaclick/php-webdriver.git", + "reference": "981db0846ff4ac5be0301d609e36e2f023e74301" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/instaclick/php-webdriver/zipball/981db0846ff4ac5be0301d609e36e2f023e74301", + "reference": "981db0846ff4ac5be0301d609e36e2f023e74301", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.5", + "satooshi/php-coveralls": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "psr-0": { + "WebDriver": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Justin Bishop", + "email": "jubishop@gmail.com", + "role": "Developer" + }, + { + "name": "Anthon Pang", + "email": "apang@softwaredevelopment.ca", + "role": "Fork Maintainer" + } + ], + "description": "PHP WebDriver for Selenium 2", + "homepage": "http://instaclick.com/", + "keywords": [ + "browser", + "selenium", + "webdriver", + "webtest" + ], + "support": { + "issues": "https://github.com/instaclick/php-webdriver/issues", + "source": "https://github.com/instaclick/php-webdriver/tree/1.4.20" + }, + "time": "2025-12-04T11:20:11+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "lakion/mink-debug-extension", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfBehat/MinkDebugExtension.git", + "reference": "46daa9bc10ff52ad3d875712cdd5bd047025b787" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfBehat/MinkDebugExtension/zipball/46daa9bc10ff52ad3d875712cdd5bd047025b787", + "reference": "46daa9bc10ff52ad3d875712cdd5bd047025b787", + "shasum": "" + }, + "require": { + "behat/behat": "^3.5", + "behat/mink-extension": "^2.3", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "behat/mink-goutte-driver": "^1.2", + "behat/mink-selenium2-driver": "^1.4", + "dmore/behat-chrome-extension": "^1.3", + "dmore/chrome-mink-driver": "^2.7", + "symfony/process": "^4.4 || ^5.2" + }, + "type": "behat-extension", + "autoload": { + "psr-4": { + "FriendsOfBehat\\MinkDebugExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "description": "Debug extension for Behat", + "homepage": "https://github.com/FriendsOfBehat/MinkDebugExtension", + "keywords": [ + "Behat", + "Mink", + "debug", + "logging" + ], + "support": { + "issues": "https://github.com/FriendsOfBehat/MinkDebugExtension/issues", + "source": "https://github.com/FriendsOfBehat/MinkDebugExtension/tree/v2.0.0" + }, + "abandoned": "friends-of-behat/mink-debug-extension", + "time": "2020-12-02T11:45:44+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nelmio/alice", + "version": "3.15.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/alice.git", + "reference": "9cd00ce94b91be0f99fd969b7e8b591e5780fe53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/alice/zipball/9cd00ce94b91be0f99fd969b7e8b591e5780fe53", + "reference": "9cd00ce94b91be0f99fd969b7e8b591e5780fe53", + "shasum": "" + }, + "require": { + "fakerphp/faker": "^1.10", + "myclabs/deep-copy": "^1.10", + "php": "^8.2", + "sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/polyfill-php84": "^1.31", + "symfony/property-access": "^6.4 || ^7.4", + "symfony/yaml": "^6.0 || ^7.4" + }, + "conflict": { + "symfony/framework-bundle": "<6.4.0 || >=7.0 <7.4.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "phpspec/prophecy": "^1.6", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^11", + "symfony/config": "^6.4 || ^7.4", + "symfony/dependency-injection": "^6.4 || ^7.4", + "symfony/finder": "^6.4 || ^7.4", + "symfony/http-kernel": "^6.4 || ^7.4", + "symfony/var-dumper": "^6.4 || ^7.4" + }, + "suggest": { + "theofidry/alice-data-fixtures": "Wrapper for Alice to provide a persistence layer." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "files": [ + "src/deep_clone.php" + ], + "psr-4": { + "Nelmio\\Alice\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + }, + { + "name": "Tim Shelburne", + "email": "shelburt02@gmail.com" + }, + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Expressive fixtures generator", + "keywords": [ + "Fixture", + "data", + "faker", + "test" + ], + "support": { + "issues": "https://github.com/nelmio/alice/issues", + "source": "https://github.com/nelmio/alice/tree/3.15.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-12-09T11:29:11+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "ondram/ci-detector", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/OndraM/ci-detector.git", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.13.2", + "lmc/coding-standard": "^3.0.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.1.0", + "phpstan/phpstan": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpunit/phpunit": "^9.6.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "OndraM\\CiDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Machulda", + "email": "ondrej.machulda@gmail.com" + } + ], + "description": "Detect continuous integration environment and provide unified access to properties of current build", + "keywords": [ + "CircleCI", + "Codeship", + "Wercker", + "adapter", + "appveyor", + "aws", + "aws codebuild", + "azure", + "azure devops", + "azure pipelines", + "bamboo", + "bitbucket", + "buddy", + "ci-info", + "codebuild", + "continuous integration", + "continuousphp", + "devops", + "drone", + "github", + "gitlab", + "interface", + "jenkins", + "pipelines", + "sourcehut", + "teamcity", + "travis" + ], + "support": { + "issues": "https://github.com/OndraM/ci-detector/issues", + "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" + }, + "time": "2024-03-12T13:22:30+00:00" + }, + { + "name": "pdepend/pdepend", + "version": "2.16.2", + "source": { + "type": "git", + "url": "https://github.com/pdepend/pdepend.git", + "reference": "f942b208dc2a0868454d01b29f0c75bbcfc6ed58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pdepend/pdepend/zipball/f942b208dc2a0868454d01b29f0c75bbcfc6ed58", + "reference": "f942b208dc2a0868454d01b29f0c75bbcfc6ed58", + "shasum": "" + }, + "require": { + "php": ">=5.3.7", + "symfony/config": "^2.3.0|^3|^4|^5|^6.0|^7.0", + "symfony/dependency-injection": "^2.3.0|^3|^4|^5|^6.0|^7.0", + "symfony/filesystem": "^2.3.0|^3|^4|^5|^6.0|^7.0", + "symfony/polyfill-mbstring": "^1.19" + }, + "require-dev": { + "easy-doc/easy-doc": "0.0.0|^1.2.3", + "gregwar/rst": "^1.0", + "squizlabs/php_codesniffer": "^2.0.0" + }, + "bin": [ + "src/bin/pdepend" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "PDepend\\": "src/main/php/PDepend" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Official version of pdepend to be handled with Composer", + "keywords": [ + "PHP Depend", + "PHP_Depend", + "dev", + "pdepend" + ], + "support": { + "issues": "https://github.com/pdepend/pdepend/issues", + "source": "https://github.com/pdepend/pdepend/tree/2.16.2" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/pdepend/pdepend", + "type": "tidelift" + } + ], + "time": "2023-12-17T18:09:59+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "0.* || ^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool checks the syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "keywords": [ + "lint", + "static analysis" + ], + "support": { + "issues": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/issues", + "source": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/tree/v1.4.0" + }, + "time": "2024-03-27T12:14:49+00:00" + }, + { + "name": "phpmd/phpmd", + "version": "2.15.0", + "source": { + "type": "git", + "url": "https://github.com/phpmd/phpmd.git", + "reference": "74a1f56e33afad4128b886e334093e98e1b5e7c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpmd/phpmd/zipball/74a1f56e33afad4128b886e334093e98e1b5e7c0", + "reference": "74a1f56e33afad4128b886e334093e98e1b5e7c0", + "shasum": "" + }, + "require": { + "composer/xdebug-handler": "^1.0 || ^2.0 || ^3.0", + "ext-xml": "*", + "pdepend/pdepend": "^2.16.1", + "php": ">=5.3.9" + }, + "require-dev": { + "easy-doc/easy-doc": "0.0.0 || ^1.3.2", + "ext-json": "*", + "ext-simplexml": "*", + "gregwar/rst": "^1.0", + "mikey179/vfsstream": "^1.6.8", + "squizlabs/php_codesniffer": "^2.9.2 || ^3.7.2" + }, + "bin": [ + "src/bin/phpmd" + ], + "type": "library", + "autoload": { + "psr-0": { + "PHPMD\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Manuel Pichler", + "email": "github@manuel-pichler.de", + "homepage": "https://github.com/manuelpichler", + "role": "Project Founder" + }, + { + "name": "Marc Würth", + "email": "ravage@bluewin.ch", + "homepage": "https://github.com/ravage84", + "role": "Project Maintainer" + }, + { + "name": "Other contributors", + "homepage": "https://github.com/phpmd/phpmd/graphs/contributors", + "role": "Contributors" + } + ], + "description": "PHPMD is a spin-off project of PHP Depend and aims to be a PHP equivalent of the well known Java tool PMD.", + "homepage": "https://phpmd.org/", + "keywords": [ + "dev", + "mess detection", + "mess detector", + "pdepend", + "phpmd", + "pmd" + ], + "support": { + "irc": "irc://irc.freenode.org/phpmd", + "issues": "https://github.com/phpmd/phpmd/issues", + "source": "https://github.com/phpmd/phpmd/tree/2.15.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/phpmd/phpmd", + "type": "tidelift" + } + ], + "time": "2023-12-11T08:22:20+00:00" + }, + { + "name": "phpro/grumphp", + "version": "v2.25.0", + "source": { + "type": "git", + "url": "https://github.com/phpro/grumphp.git", + "reference": "8af668884c838382e5114404204e62385ce3cdba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpro/grumphp/zipball/8af668884c838382e5114404204e62385ce3cdba", + "reference": "8af668884c838382e5114404204e62385ce3cdba", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.0", + "amphp/parallel": "^2.1", + "composer-plugin-api": "^2.0", + "doctrine/collections": "^1.6.8 || ^2.0 || ^3.0", + "ext-json": "*", + "gitonomy/gitlib": "^1.6", + "laravel/serializable-closure": "^2.0", + "monolog/monolog": "^2.0 || ^3.0", + "ondram/ci-detector": "^4.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.1 || ^2.0", + "seld/jsonlint": "^1.8", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/dotenv": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.0 || ^8.0", + "symfony/process": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "composer/composer": "^2.9.6", + "nikic/php-parser": "^5.7", + "php-cs-fixer/shim": "^3.93", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/phpspec": "^8.2", + "phpspec/prophecy": "^1.24", + "phpspec/prophecy-phpunit": "^2.4", + "phpunit/phpunit": "^11.5.50" + }, + "suggest": { + "atoum/atoum": "Lets GrumPHP run your unit tests.", + "behat/behat": "Lets GrumPHP validate your project features.", + "brianium/paratest": "Lets GrumPHP run PHPUnit in parallel.", + "carthage-software/mago": "Lets GrumPHP help you write better PHP code.", + "codeception/codeception": "Lets GrumPHP run your project's full stack tests", + "consolidation/robo": "Lets GrumPHP run your automated PHP tasks.", + "designsecurity/progpilot": "Lets GrumPHP be sure that there are no vulnerabilities in your code.", + "doctrine/orm": "Lets GrumPHP validate your Doctrine mapping files.", + "enlightn/security-checker": "Lets GrumPHP be sure that there are no known security issues.", + "ergebnis/composer-normalize": "Lets GrumPHP tidy and normalize your composer.json file.", + "friendsofphp/php-cs-fixer": "Lets GrumPHP automatically fix your codestyle.", + "friendsoftwig/twigcs": "Lets GrumPHP check Twig coding standard.", + "infection/infection": "Lets GrumPHP evaluate the quality your unit tests", + "maglnet/composer-require-checker": "Lets GrumPHP analyze composer dependencies.", + "malukenho/kawaii-gherkin": "Lets GrumPHP lint your Gherkin files.", + "nette/tester": "Lets GrumPHP run your unit tests with nette tester.", + "nikic/php-parser": "Lets GrumPHP run static analyses through your PHP files.", + "pestphp/pest": "Lets GrumPHP run your unit test with Pest PHP", + "phan/phan": "Lets GrumPHP unleash a static analyzer on your code", + "phing/phing": "Lets GrumPHP run your automated PHP tasks.", + "php-parallel-lint/php-parallel-lint": "Lets GrumPHP quickly lint your entire code base.", + "phparkitect/phparkitect": "Let GrumPHP keep your codebase coherent and solid, by permitting to add some architectural constraint check to your workflow.", + "phpmd/phpmd": "Lets GrumPHP sort out the mess in your code", + "phpspec/phpspec": "Lets GrumPHP spec your code.", + "phpstan/phpstan": "Lets GrumPHP discover bugs in your code without running it.", + "phpunit/phpunit": "Lets GrumPHP run your unit tests.", + "povils/phpmnd": "Lets GrumPHP help you detect magic numbers in PHP code.", + "rector/rector ": "Lets GrumPHP instantly upgrade and automatically refactor your PHP code.", + "roave/security-advisories": "Lets GrumPHP be sure that there are no known security issues.", + "sebastian/phpcpd": "Lets GrumPHP find duplicated code.", + "squizlabs/php_codesniffer": "Lets GrumPHP sniff on your code.", + "sstalle/php7cc": "Lets GrumPHP check PHP 5.3 - 5.6 code compatibility with PHP 7.", + "symfony/phpunit-bridge": "Lets GrumPHP run your unit tests with the phpunit-bridge of Symfony.", + "symplify/easy-coding-standard": "Lets GrumPHP check coding standard.", + "vimeo/psalm": "Lets GrumPHP discover errors in your code without running it.", + "vincentlanglet/twig-cs-fixer": "Lets GrumPHP check and fix twig coding standard." + }, + "bin": [ + "bin/grumphp" + ], + "type": "composer-plugin", + "extra": { + "class": "GrumPHP\\Composer\\GrumPHPPlugin" + }, + "autoload": { + "psr-4": { + "GrumPHP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Toon Verwerft", + "email": "toon.verwerft@phpro.be" + }, + { + "name": "Community", + "homepage": "https://github.com/phpro/grumphp/graphs/contributors" + } + ], + "description": "A composer plugin that enables source code quality checks.", + "support": { + "issues": "https://github.com/phpro/grumphp/issues", + "source": "https://github.com/phpro/grumphp/tree/v2.25.0" + }, + "time": "2026-10-03T08:58:23+00:00" + }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.6", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + }, + "time": "2026-09-27T20:07:45+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "50d276fc3bf1430ec315f2f109bbde2769821524" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/50d276fc3bf1430ec315f2f109bbde2769821524", + "reference": "50d276fc3bf1430ec315f2f109bbde2769821524", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2024-12-17T17:14:01+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "bdb6a835c5aa9725979694ae9b70591e180f4853" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/bdb6a835c5aa9725979694ae9b70591e180f4853", + "reference": "bdb6a835c5aa9725979694ae9b70591e180f4853", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0.3" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.2", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "symfony/cache": "^5.4" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.1" + }, + "time": "2024-12-02T16:48:00+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3", + "reference": "ed6fea0ad4ad9c7e25f3ad2e7c4d420cf1e67fe3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.1" + }, + "time": "2024-12-12T20:21:10+00:00" + }, + { + "name": "phpstan/phpstan-webmozart-assert", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-webmozart-assert.git", + "reference": "0c641817d2a8f05c7157f92d91986e74d3c8ab0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-webmozart-assert/zipball/0c641817d2a8f05c7157f92d91986e74d3c8ab0c", + "reference": "0c641817d2a8f05c7157f92d91986e74d3c8ab0c", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "nikic/php-parser": "^5.1", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "webmozart/assert": "^1.11.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan webmozart/assert extension", + "support": { + "issues": "https://github.com/phpstan/phpstan-webmozart-assert/issues", + "source": "https://github.com/phpstan/phpstan-webmozart-assert/tree/2.0.0" + }, + "time": "2024-10-14T03:45:26+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.38", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8b5f757e8e92071bfdbfa93943f94f84c4e31007", + "reference": "8b5f757e8e92071bfdbfa93943f94f84c4e31007", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.38" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:44:26+00:00" + }, + { + "name": "phrity/net-stream", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/sirn-se/phrity-net-stream.git", + "reference": "9105931b65ad90c75f4885a40b268b0f65802e3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sirn-se/phrity-net-stream/zipball/9105931b65ad90c75f4885a40b268b0f65802e3e", + "reference": "9105931b65ad90c75f4885a40b268b0f65802e3e", + "shasum": "" + }, + "require": { + "php": "^7.4 | ^8.0", + "phrity/util-errorhandler": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 | ^2.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpunit/phpunit": "^9.0 | ^10.0", + "phrity/net-uri": "^1.1", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Phrity\\Net\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sören Jensen", + "email": "sirn@sirn.se", + "homepage": "https://phrity.sirn.se" + } + ], + "description": "Socket stream classes implementing PSR-7 Stream and PSR-17 StreamFactory", + "homepage": "https://phrity.sirn.se/net-stream", + "keywords": [ + "Socket", + "client", + "psr-17", + "psr-7", + "server", + "stream", + "stream factory" + ], + "support": { + "issues": "https://github.com/sirn-se/phrity-net-stream/issues", + "source": "https://github.com/sirn-se/phrity-net-stream/tree/1.3.0" + }, + "time": "2023-10-22T10:47:03+00:00" + }, + { + "name": "phrity/net-uri", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/sirn-se/phrity-net-uri.git", + "reference": "3f458e0c4d1ddc0e218d7a5b9420127c63925f43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sirn-se/phrity-net-uri/zipball/3f458e0c4d1ddc0e218d7a5b9420127c63925f43", + "reference": "3f458e0c4d1ddc0e218d7a5b9420127c63925f43", + "shasum": "" + }, + "require": { + "php": "^7.4 | ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 | ^2.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpunit/phpunit": "^9.0 | ^10.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Phrity\\Net\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sören Jensen", + "email": "sirn@sirn.se", + "homepage": "https://phrity.sirn.se" + } + ], + "description": "PSR-7 Uri and PSR-17 UriFactory implementation", + "homepage": "https://phrity.sirn.se/net-uri", + "keywords": [ + "psr-17", + "psr-7", + "uri", + "uri factory" + ], + "support": { + "issues": "https://github.com/sirn-se/phrity-net-uri/issues", + "source": "https://github.com/sirn-se/phrity-net-uri/tree/1.3.0" + }, + "time": "2023-08-21T10:33:06+00:00" + }, + { + "name": "phrity/util-errorhandler", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/sirn-se/phrity-util-errorhandler.git", + "reference": "70a669cc22db2eed6a109ec66fd95168a4332c9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sirn-se/phrity-util-errorhandler/zipball/70a669cc22db2eed6a109ec66fd95168a4332c9b", + "reference": "70a669cc22db2eed6a109ec66fd95168a4332c9b", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "robiningelbrecht/phpunit-coverage-tools": "^1.9", + "squizlabs/php_codesniffer": "^3.5 || ^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Phrity\\Util\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sören Jensen", + "email": "sirn@sirn.se", + "homepage": "https://phrity.sirn.se" + } + ], + "description": "Inline error handler; catch and resolve errors for code block.", + "homepage": "https://phrity.sirn.se/util-errorhandler", + "keywords": [ + "error", + "warning" + ], + "support": { + "issues": "https://github.com/sirn-se/phrity-util-errorhandler/issues", + "source": "https://github.com/sirn-se/phrity-util-errorhandler/tree/1.2.2" + }, + "time": "2025-12-05T21:25:36+00:00" + }, + { + "name": "phrity/websocket", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/sirn-se/websocket-php.git", + "reference": "8a525da4457b599ab1960f24183f25626c96ce3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sirn-se/websocket-php/zipball/8a525da4457b599ab1960f24183f25626c96ce3c", + "reference": "8a525da4457b599ab1960f24183f25626c96ce3c", + "shasum": "" + }, + "require": { + "php": "^7.4 | ^8.0", + "phrity/net-stream": "^1.2", + "phrity/net-uri": "^1.2", + "phrity/util-errorhandler": "^1.0", + "psr/http-message": "^1.1 | ^2.0", + "psr/log": "^1.0 | ^2.0 | ^3.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpunit/phpunit": "^9.0 | ^10.0", + "phrity/net-mock": "^1.3", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "WebSocket\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Fredrik Liljegren" + }, + { + "name": "Sören Jensen", + "email": "sirn@sirn.se", + "homepage": "https://phrity.sirn.se" + } + ], + "description": "WebSocket client and server", + "homepage": "https://phrity.sirn.se/websocket", + "keywords": [ + "client", + "server", + "websocket" + ], + "support": { + "issues": "https://github.com/sirn-se/websocket-php/issues", + "source": "https://github.com/sirn-se/websocket-php/tree/1.7.3" + }, + "time": "2024-05-31T13:43:32+00:00" + }, + { + "name": "rector/rector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "df5de7b80deced1ea7f719a0b4d02e4aee87dd21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/df5de7b80deced1ea7f719a0b4d02e4aee87dd21", + "reference": "df5de7b80deced1ea7f719a0b4d02e4aee87dd21", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.0.4" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2024-12-26T23:06:19+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-08-11T04:55:59+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "c85be6922b7fd365942b986b9a50397d65407611" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-11T05:25:24+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9a90eb5d32d5a500296bf43f946d60246444d5f7", + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2026-06-12T11:32:29+00:00" + }, + { + "name": "slevomat/coding-standard", + "version": "8.22.1", + "source": { + "type": "git", + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/1dd80bf3b93692bedb21a6623c496887fad05fec", + "reference": "1dd80bf3b93692bedb21a6623c496887fad05fec", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.1.2", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^2.3.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "phing/phing": "3.0.1|3.1.0", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/phpstan": "2.1.24", + "phpstan/phpstan-deprecation-rules": "2.0.3", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "2.0.6", + "phpunit/phpunit": "9.6.8|10.5.48|11.4.4|11.5.36|12.3.10" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "keywords": [ + "dev", + "phpcs" + ], + "support": { + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/8.22.1" + }, + "funding": [ + { + "url": "https://github.com/kukulich", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" + } + ], + "time": "2025-09-13T08:53:30+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.6", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-08-06T00:17:32+00:00" + }, + { + "name": "sylius-labs/coding-standard", + "version": "v4.5.1", + "source": { + "type": "git", + "url": "https://github.com/SyliusLabs/CodingStandard.git", + "reference": "8543560abff256e185f3ff3a0ff566968d1d9fb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SyliusLabs/CodingStandard/zipball/8543560abff256e185f3ff3a0ff566968d1d9fb7", + "reference": "8543560abff256e185f3ff3a0ff566968d1d9fb7", + "shasum": "" + }, + "require": { + "php": "^8.0", + "slevomat/coding-standard": "^8.0", + "symplify/easy-coding-standard": "^10.0 || ^11.0 || ^12.0 || ^13.0" + }, + "conflict": { + "slevomat/coding-standard": ">=8.23", + "symplify/easy-coding-standard": ">=13.1.3", + "symplify/package-builder": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kamil Kokot", + "email": "kamil@kokot.me", + "homepage": "https://kamilkokot.com" + } + ], + "description": "Battle-tested coding standard configuration used in Sylius.", + "support": { + "issues": "https://github.com/SyliusLabs/CodingStandard/issues", + "source": "https://github.com/SyliusLabs/CodingStandard/tree/v4.5.1" + }, + "time": "2026-05-05T13:14:49+00:00" + }, + { + "name": "sylius/test-application", + "version": "v2.2.0-ALPHA.1", + "source": { + "type": "git", + "url": "https://github.com/Sylius/TestApplication.git", + "reference": "a53d99b62a7d48a635690ebf6f55992b896dc8c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/TestApplication/zipball/a53d99b62a7d48a635690ebf6f55992b896dc8c1", + "reference": "a53d99b62a7d48a635690ebf6f55992b896dc8c1", + "shasum": "" + }, + "require": { + "php": "^8.2", + "sylius/sylius": "~2.2.0", + "symfony/debug-bundle": "*", + "symfony/dotenv": "*", + "symfony/flex": "*", + "symfony/runtime": "*", + "symfony/web-profiler-bundle": "*", + "theofidry/alice-data-fixtures": "*" + }, + "bin": [ + "bin/console" + ], + "type": "library", + "autoload": { + "psr-4": { + "Sylius\\TestApplication\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "support": { + "issues": "https://github.com/Sylius/TestApplication/issues", + "source": "https://github.com/Sylius/TestApplication/tree/v2.2.0-ALPHA.1" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2025-12-22T09:59:03+00:00" + }, + { + "name": "symfony/apache-pack", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/apache-pack.git", + "reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/apache-pack/zipball/3aa5818d73ad2551281fc58a75afd9ca82622e6c", + "reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c", + "shasum": "" + }, + "type": "symfony-pack", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A pack for Apache support in Symfony", + "support": { + "issues": "https://github.com/symfony/apache-pack/issues", + "source": "https://github.com/symfony/apache-pack/tree/master" + }, + "time": "2017-12-12T01:46:35+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "4f631476bb549d882a173c47bf693f3ef84c722d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/4f631476bb549d882a173c47bf693f3ef84c722d", + "reference": "4f631476bb549d882a173c47bf693f3ef84c722d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dom-crawler": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T15:19:05+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "5484e316cd8125f5215bbf829151e2211aa6d101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/5484e316cd8125f5215bbf829151e2211aa6d101", + "reference": "5484e316cd8125f5215bbf829151e2211aa6d101", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T07:35:46+00:00" + }, + { + "name": "symfony/debug-bundle", + "version": "v6.4.35", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "eb79084c2c9778559b21f61cb1507cbd580cc6e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/eb79084c2c9778559b21f61cb1507cbd580cc6e1", + "reference": "eb79084c2c9778559b21f61cb1507cbd580cc6e1", + "shasum": "" + }, + "require": { + "ext-xml": "*", + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/twig-bridge": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/dependency-injection": "<5.4" + }, + "require-dev": { + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\DebugBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug-bundle/tree/v6.4.35" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-02T09:25:10+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "998855f6c3699f148b54096d1d7d2b8b0b0fc298" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/998855f6c3699f148b54096d1d7d2b8b0b0fc298", + "reference": "998855f6c3699f148b54096d1d7d2b8b0b0fc298", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T17:31:07+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "9b827002b54f89a5ac605c21c349a42161996afb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/9b827002b54f89a5ac605c21c349a42161996afb", + "reference": "9b827002b54f89a5ac605c21c349a42161996afb", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/process": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-26T10:10:07+00:00" + }, + { + "name": "symfony/flex", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/flex.git", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1", + "php": ">=8.1" + }, + "conflict": { + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" + }, + "require-dev": { + "composer/composer": "^2.1", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" + }, + "autoload": { + "psr-4": { + "Symfony\\Flex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T17:25:22+00:00" + }, + { + "name": "symfony/runtime", + "version": "v6.4.41", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "4a354e15532ed4f99c6d0fdc5618bfbd3ea05a8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/4a354e15532ed4f99c6d0fdc5618bfbd3ea05a8a", + "reference": "4a354e15532ed4f99c6d0fdc5618bfbd3ea05a8a", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.1" + }, + "conflict": { + "symfony/dotenv": "<5.4" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "symfony/console": "^5.4.9|^6.0.9|^7.0", + "symfony/dotenv": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/tree/v6.4.41" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T14:01:00+00:00" + }, + { + "name": "symfony/web-profiler-bundle", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "df8f3c9f05c08cd7aeab029b4c6af456265c3666" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/df8f3c9f05c08cd7aeab029b4c6af456265c3666", + "reference": "df8f3c9f05c08cd7aeab029b4c6af456265c3666", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0", + "twig/twig": "^2.13|^3.0.4|^4.0" + }, + "conflict": { + "symfony/form": "<5.4", + "symfony/mailer": "<5.4", + "symfony/messenger": "<5.4", + "symfony/twig-bundle": ">=7.0" + }, + "require-dev": { + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], + "support": { + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T16:09:37+00:00" + }, + { + "name": "symplify/easy-coding-standard", + "version": "13.1.2", + "source": { + "type": "git", + "url": "https://github.com/ecsphp/ecs.git", + "reference": "6d22473d1f36945884d8cb291777166020a47770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ecsphp/ecs/zipball/6d22473d1f36945884d8cb291777166020a47770", + "reference": "6d22473d1f36945884d8cb291777166020a47770", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "friendsofphp/php-cs-fixer": "<3.92.4", + "phpcsstandards/php_codesniffer": "<4.0.1", + "symplify/coding-standard": "<12.1" + }, + "suggest": { + "ext-dom": "Needed to support checkstyle output format in class CheckstyleOutputFormatter" + }, + "bin": [ + "bin/ecs" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Use Coding Standard with 0-knowledge of PHP-CS-Fixer and PHP_CodeSniffer", + "keywords": [ + "Code style", + "automation", + "fixer", + "static analysis" + ], + "support": { + "issues": "https://github.com/easy-coding-standard/ecs/issues", + "source": "https://github.com/easy-coding-standard/ecs/tree/13.1.2" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2026-05-03T22:05:09+00:00" + }, + { + "name": "theofidry/alice-data-fixtures", + "version": "1.7.2", + "source": { + "type": "git", + "url": "https://github.com/theofidry/AliceDataFixtures.git", + "reference": "39a2fb2d83d683bec58e9fa0c1e22feceb17056e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/AliceDataFixtures/zipball/39a2fb2d83d683bec58e9fa0c1e22feceb17056e", + "reference": "39a2fb2d83d683bec58e9fa0c1e22feceb17056e", + "shasum": "" + }, + "require": { + "nelmio/alice": "^3.10", + "php": "^8.2", + "psr/log": "^1 || ^2 || ^3", + "webmozart/assert": "^1.10" + }, + "conflict": { + "doctrine/dbal": "<3.0", + "doctrine/orm": "<2.6.3", + "doctrine/persistence": "<2.0", + "illuminate/database": "<8.12", + "ocramius/proxy-manager": "<2.1", + "symfony/framework-bundle": "<5.4 || >=6.0 <6.4", + "zendframework/zend-code": "<3.3.1" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/annotations": "^1.13", + "phpspec/prophecy": "^1.14.0", + "phpspec/prophecy-phpunit": "^2.0.1", + "phpunit/phpunit": "^9.5.10", + "symfony/phpunit-bridge": "^5.3.8 || ^6.4" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "To use Doctrine with the MongoDB flavour", + "doctrine/data-fixtures": "To use Doctrine", + "doctrine/dbal": "To use Doctrine with the PHPCR flavour", + "doctrine/mongodb": "To use Doctrine with the MongoDB flavour", + "doctrine/mongodb-odm": "To use Doctrine with the MongoDB flavour", + "doctrine/orm": "To use Doctrine ORM", + "doctrine/phpcr-odm": "To use Doctrine with the PHPCR flavour", + "illuminate/database": "To use Eloquent", + "jackalope/jackalope-doctrine-dbal": "To use Doctrine with the PHPCR flavour", + "ocramius/proxy-manager": "To avoid database connection on kernel boot" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fidry\\AliceDataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com", + "homepage": "https://github.com/theofidry" + } + ], + "description": "Nelmio alice extension to persist the loaded fixtures.", + "keywords": [ + "Fixture", + "alice", + "data", + "faker", + "orm", + "tests" + ], + "support": { + "issues": "https://github.com/theofidry/AliceDataFixtures/issues", + "source": "https://github.com/theofidry/AliceDataFixtures/tree/1.7.2" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2024-07-05T21:18:40+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "sylius/test-application": 15 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/routing/admin.yaml b/config/routing/admin.yaml index 4800b8e9..f25c4ed1 100644 --- a/config/routing/admin.yaml +++ b/config/routing/admin.yaml @@ -2,4 +2,20 @@ controllers: resource: path: '../../src/Action/Admin/' namespace: 'PayPlug\SyliusPayPlugPlugin\Action\Admin' - type: attribute \ No newline at end of file + type: attribute +# Declared here rather than with #[Route] attributes — see AuthorizedPaymentController's docblock. +payplug_sylius_admin_payment_capture: + path: /payplug/orders/{orderId}/payments/{paymentId}/capture + methods: [POST] + controller: PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController::capture + requirements: + orderId: \d+ + paymentId: \d+ + +payplug_sylius_admin_payment_cancel: + path: /payplug/orders/{orderId}/payments/{paymentId}/cancel + methods: [POST] + controller: PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController::cancel + requirements: + orderId: \d+ + paymentId: \d+ diff --git a/config/services.yaml b/config/services.yaml index 7db6a5fe..328a2c40 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,3 +1,24 @@ +parameters: + # Overridable via env vars for QA/staging testing; merchants installing the plugin normally + # never need to set these — the defaults below are used automatically. + payplug.oauth_base_url.default: 'https://api.payplug.com' + payplug.oauth_base_url: '%env(default:payplug.oauth_base_url.default:PAYPLUG_OAUTH_BASE_URL)%' + payplug.oauth_audience.default: 'https://www.payplug.com' + payplug.oauth_audience: '%env(default:payplug.oauth_audience.default:PAYPLUG_OAUTH_AUDIENCE)%' + payplug.unified_api_base_url.default: 'https://api.payplug.com' + payplug.unified_api_base_url: '%env(default:payplug.unified_api_base_url.default:PAYPLUG_UNIFIED_API_BASE_URL)%' + # Escape hatch for QA/staging hosts whose TLS certificate is signed by an internal CA the + # merchant's PHP install has no way to obtain or trust. MUST default to true (verification on) + # and MUST NOT be disabled in production — this only exists so a QA/staging environment can + # explicitly opt out for its own known-internal host, not as a general "skip TLS" switch. + payplug.unified_api_verify_tls.default: true + payplug.unified_api_verify_tls: '%env(bool:default:payplug.unified_api_verify_tls.default:PAYPLUG_UNIFIED_API_VERIFY_TLS)%' + # Hosted Fields JS SDK loaded by the checkout: one full https URL, because the host, the path + # and the pinned version all differ per environment. Defaults to the production SDK; QA and + # staging setups set PAYPLUG_HOSTED_FIELDS_SDK_URL to their own SDK URL. + payplug.hosted_fields_sdk_url.default: 'https://js.dalenys.com/hosted-fields/v2.2.0/hosted-fields.min.js' + payplug.hosted_fields_sdk_url: '%env(default:payplug.hosted_fields_sdk_url.default:PAYPLUG_HOSTED_FIELDS_SDK_URL)%' + services: _defaults: autowire: true @@ -9,6 +30,22 @@ services: exclude: '../src/{ApiClient,DependencyInjection,Entity,Exception,Model,Repository,PayPlugSyliusPayPlugPlugin.php}' bind: Psr\Log\LoggerInterface: '@monolog.logger.payplug' + $payplugOauthBaseUrl: '%payplug.oauth_base_url%' + $payplugOauthAudience: '%payplug.oauth_audience%' + $unifiedApiBaseUrl: '%payplug.unified_api_base_url%' + $unifiedApiVerifyTls: '%payplug.unified_api_verify_tls%' + $hostedFieldsSdkUrl: '%payplug.hosted_fields_sdk_url%' + + # `security.csrf.token_manager` is only registered while CSRF protection is enabled, so it is + # injected with `@?`: a hard reference would break container compilation for an application + # that turns it off, rather than degrading the way Sylius's own admin actions do. + PayPlug\SyliusPayPlugPlugin\Action\Admin\Auth\UnifiedLogoutController: + arguments: + $csrfTokenManager: '@?security.csrf.token_manager' + + PayPlug\SyliusPayPlugPlugin\Action\Admin\AuthorizedPaymentController: + arguments: + $csrfTokenManager: '@?security.csrf.token_manager' PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository @@ -25,6 +62,47 @@ services: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProviderInterface: class: PayPlug\SyliusPayPlugPlugin\Provider\OneySimulation\OneySimulationDataProvider + # Alias (not a separate definition) so the interface resolves to the service auto-registered by the + # `PayPlug\SyliusPayPlugPlugin\:` prototype above, keeping its `@monolog.logger.payplug` binding. + PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface: + alias: PayPlug\SyliusPayPlugPlugin\PaymentProcessing\NullHostedFieldsPaymentProcessor + + PayplugUnifiedCore\Contracts\ILogger: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcLogger + + PayplugUnifiedCore\Contracts\ILock: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcLock + + PayplugUnifiedCore\Contracts\IUnifiedApiHttpClient: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUnifiedApiHttpClient + + # Deliberately NOT aliased: PayplugUnifiedCore\Contracts\IConfigurationRepository carries no + # account context on any of its methods, and since PRE-3628 a merchant may hold several CB + # gateway configs — an unscoped SyliusUpcConfigurationRepository throws on every read. Aliasing + # it would let a future UPC consumer autowire a service that blows up at runtime, in whatever + # code path UPC happened to choose; without the alias it fails at container build instead. + # Consumers take ScopedConfigurationRepositoryInterface and scope it to their payment method. + PayPlug\SyliusPayPlugPlugin\Upc\ScopedConfigurationRepositoryInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusUpcConfigurationRepository + + PayplugUnifiedCore\Contracts\IPaymentRepository: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusPaymentOperationRepository + + PayplugUnifiedCore\Contracts\IOrderStateMutator: + alias: PayPlug\SyliusPayPlugPlugin\Upc\SyliusOrderStateMutator + + PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreatorInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiPaymentCreator + + PayPlug\SyliusPayPlugPlugin\Upc\OperationStatusFetcherInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiOperationStatusFetcher + + PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiRefundCreator + + PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationOperatorInterface: + alias: PayPlug\SyliusPayPlugPlugin\Upc\UnifiedApiAuthorizationOperator + payplug_sylius_payplug_plugin.action.capture: class: PayPlug\SyliusPayPlugPlugin\Action\CaptureAction diff --git a/config/services/client.xml b/config/services/client.xml index 3f2d3391..73148c93 100644 --- a/config/services/client.xml +++ b/config/services/client.xml @@ -10,6 +10,25 @@ + + + + + + + + %payplug.oauth_base_url% + + + %payplug.oauth_audience% + + + + [!IMPORTANT] > The authorized payment feature is only available for the "PayPlug" payment gateway. +Two flows exist, depending on the payment method's display mode: + +- **Hosted Fields** (Unified API): capture — full, partial, several times — and cancellation are + driven from the admin order screen. See [Hosted Fields: capture and cancel from the order screen](#hosted-fields-capture-and-cancel-from-the-order-screen). +- **Redirected / Integrated Payment** (legacy API): the whole authorized amount is captured at + once, by the command or the state machine triggers described in [Trigger the capture](#trigger-the-capture). + ## Activation On the payment method configuration, you can enable the deferred capture feature. @@ -153,3 +160,91 @@ final class CaptureOrderProcessor } } ``` + +## Hosted Fields: capture and cancel from the order screen + +With **deferred capture** enabled on a Hosted Fields payment method, each payment is created as an +authorization only: the customer's funds are held, not debited, and the Sylius payment is +`authorized` (the order payment state reads *authorized*, not *paid*). + +On the order screen (**Sales › Orders › an order**), a **PayPlug — Deferred capture** block is shown +under *Payments* for each such payment. It displays: + +| Field | Meaning | +|---|---| +| Authorized amount | What the customer's bank agreed to hold | +| Already captured | Sum of the captures performed so far | +| Cancelled | Sum of the cancellations performed so far (shown once there is one) | +| Remaining capturable | What can still be captured or cancelled | +| Capture deadline | The date after which the authorization lapses and the funds are released. A warning is shown 48 hours before it; after it, no action is offered any more | + +### Capture + +Leave the amount blank to capture everything that remains, or type an amount (`12.50` or `12,50`) +for a partial capture. Captures can be chained as long as their total stays within the authorized +amount. + +| After the capture | Sylius payment state | +|---|---| +| Something is still capturable | stays `authorized` | +| Nothing is left | `completed` (the order becomes *paid*) | + +Sylius's own **Complete** button is hidden for these payments. A `complete` transition applied by +any other means — the `payplug:capture-authorized-payments` command, or your own shipping listener +as described above — captures the whole remaining amount first; if that capture is refused, the +transition is aborted and the payment stays `authorized`. + +### Cancel + +Cancellation is only offered **before any capture**. Leave the amount blank to cancel everything, +or type an amount for a partial cancellation — which must be enabled on the merchant's PayPlug +contract; otherwise PayPlug refuses it and the admin explains why. + +| After the cancellation | Sylius payment state | +|---|---| +| Something is still authorized | stays `authorized` (the rest can still be captured) | +| Nothing is left | `cancelled` | + +Once part of the authorization is captured, use a refund to give money back. + +### Refusals + +When PayPlug refuses an operation, nothing is recorded and the payment state is left unchanged; the +admin shows an explicit message, e.g.: + +- the authorization has expired; +- the amount exceeds what remains; +- partial cancellation is not enabled on the contract; +- the customer's bank refused the operation; +- another operation is already in progress on this payment. + +A form submitted twice (double click, browser back + resubmit) is refused as *stale*: each form +carries the state it was built against, and operations on a same payment are serialized. The +operation is saved before that serialization lock is released, so a second request can never see +the state from before it. + +If PayPlug accepted the operation but Sylius failed to save it (database error), the admin says the +operation **may have been performed** and asks to check the payment in the PayPlug portal before +retrying, and a `critical` entry is written to the log. Retrying blindly would capture twice. + +### Partial operations and Sylius refunds + +Sylius has no notion of a partial capture or a partial cancellation, so the plugin does not try to +bend it: `Payment::amount` stays at the authorized amount, and Sylius's order payment state and +RefundPlugin's refundable total are left untouched. What was really captured is tracked in the +payment details; refunds sent to PayPlug are computed from it. After a partial cancellation followed +by a capture of the rest, the order therefore reads as paid for the authorized amount, while the +captured amount is the smaller one shown in the block on the order screen. + +### Notifications and operations performed outside Sylius + +The webhook confirming each capture or cancellation is matched to the operation Sylius triggered. +If PayPlug later reports that an operation accepted earlier did **not** go through, it is flagged +as failed in the payment details (the remaining amount counts it back in) and a `critical` entry is +written to the `payplug` log channel so that the payment can be reconciled manually. + +> [!WARNING] +> Captures and cancellations performed **outside Sylius** (PayPlug portal, PayPlug support, another +> integration using the same account) are not reflected in Sylius: they carry operation ids Sylius +> does not know. Perform them from the Sylius order screen so that the payment state stays in step. + diff --git a/features/shop/hosted_fields_payment_and_webhook_flow.feature b/features/shop/hosted_fields_payment_and_webhook_flow.feature new file mode 100644 index 00000000..5cb81677 --- /dev/null +++ b/features/shop/hosted_fields_payment_and_webhook_flow.feature @@ -0,0 +1,80 @@ +# PRE-3551 / Task 14 — end-to-end coverage for the payplug_uhf (Unified Hosted Fields) capture + +# UPC webhook pipeline built by Tasks 1-13. +# +# IMPORTANT — read before relying on this file: +# +# This file is WRITTEN BUT NOT EXECUTABLE YET. Every step in the Background and in the opening +# "Given"/"When"/"Then" lines that also appear in features/shop/hosted_fields_payment_method.feature +# or features/shop/update_payment_state.feature is backed by a real, already-merged step +# definition and is safe to trust. Steps marked "# NEW — not yet defined" below do not resolve to +# any step definition in this codebase today; Behat would report them as "undefined", not pass or +# fail. See tests-Behat/task-14-report.md (PRE-3551 SDD folder) for why, and for a sketch of the +# infrastructure (Mocker + a raw-HTTP webhook page object + a PaymentRequest fixture step) that +# would need to be designed and built — not guessed — before these two scenarios can actually run. +@paying_with_payplug_for_order +Feature: Paying with Unified Hosted Fields and receiving the UPC webhook confirmation + In order to buy products with a card processed through PayPlug's Unified API + As a Customer + I want my order to be confirmed once the asynchronous UPC webhook notification arrives + + Background: + Given the store operates on a single channel in "United States" + And that channel also allows to shop using the "EUR" currency + And there is a user "john@bitbag.pl" identified by "password123" + And I changed my currency to "EUR" + And the store has a payment method "Unified Hosted Fields" with a code "payplug_uhf" and PayPlug Hosted Fields payment gateway + And This secret Key is valid + And the store ships everywhere for free + And the store has "DHL" shipping method with "$0.00" fee + And I am logged in as "john@bitbag.pl" + And the store has a product "PHP T-Shirt" priced at "€50.00" + + @ui + Scenario: Paying with Unified Hosted Fields completes the order without a 3DS redirect + Given I added product "PHP T-Shirt" to the cart + And I chose "DHL" shipping method + Then I should be on the checkout payment step + And I should be able to select "Unified Hosted Fields" payment method + And I select "Unified Hosted Fields" payment method + And I should see the "#card-container" element on the page + # NEW — not yet defined. Needs: (1) HostedPaymentCreatorInterface made a public service + # (currently private by DI default) so a Behat Mocker can swap it, mirroring + # tests/Behat/Mocker/PayPlugApiMocker's pattern for the legacy PayPlugApiClientInterface; + # (2) a new UhfApiMocker::mockCreateHostedPaymentWithoutRedirect() stubbing + # createHostedPayment() to return a HostedPaymentOutput with redirectUrl = null. + And the Unified API is stubbed to accept the payment without requiring 3DS + # NEW — not yet defined, and not just missing a step: card entry goes through a real, + # cross-origin Dalenys hostedFields iframe SDK (assets/shop/controllers/hosted-fields_controller.js) + # loaded from PayPlug's own domain. There is no server-side seam to stub this — Behat would + # need either a live sandbox tokenization endpoint or a test-only shim replacing + # window.dalenys before the page loads. Deciding whether to build that shim is a product/ + # architecture call, not a step-definition gap. + When I fill in my card details and confirm the payment + Then I should see the thank you page + # NEW — not yet defined. "Eventually" implies polling/waiting for an async webhook POST + # that nothing in this suite currently sends (see Scenario 2's blockers below). + And the order's payment should eventually be marked as paid once the webhook is delivered + + @ui + Scenario: A retried UPC webhook notification is not processed twice + # NEW — not yet defined. Needs a fixture step that creates an Order + Payment already + # past the capture step (hosted_fields_created_at set, Payment state "processing") plus a + # real Sylius PaymentRequestInterface (action=notify, hash known) so the scenario has a + # real notify URL to POST to — sylius/sylius's PaymentRequest entity/repository isn't + # available in this checkout's vendor/ to confirm the exact construction API, so writing + # this without running it would be guessing at Sylius core internals, not this plugin's. + Given an order paid with "Unified Hosted Fields" is awaiting webhook confirmation + # NEW — not yet defined. Needs a Page object that performs a raw HTTP POST (via the Mink + # BrowserKit driver's underlying client) to the sylius_payment_request_notify route for + # that PaymentRequest's hash, with body {"id","execCode","orderId","amount"} and an + # Authorization header matching whatever the "payplug_webhook_authorization_header" gateway + # config key was seeded to in the Given step above (see + # src/Upc/SyliusUpcConfigurationRepository::get() and + # src/Command/Handler/NotifyHostedPaymentRequestHandler — confirmed by + # tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest, which is the most + # reliable reference for the exact payload/header shape). + When the UPC webhook notification is delivered + And the same UPC webhook notification is delivered again + # NEW — not yet defined. No existing step asserts Payment state (only order state, via + # CheckoutContext::theLatestOrderHasState) or asserts a call count on IPaymentRepository. + Then the order's payment should be marked as paid exactly once diff --git a/features/shop/hosted_fields_payment_method.feature b/features/shop/hosted_fields_payment_method.feature new file mode 100644 index 00000000..7f22142b --- /dev/null +++ b/features/shop/hosted_fields_payment_method.feature @@ -0,0 +1,26 @@ +@paying_with_payplug_for_order +Feature: Paying with Hosted Fields during checkout + In order to buy products + As a Customer + I want to see Hosted Fields when the merchant enabled it on the PayPlug payment method + + Background: + Given the store operates on a single channel in "United States" + And that channel also allows to shop using the "EUR" currency + And there is a user "john@bitbag.pl" identified by "password123" + And I changed my currency to "EUR" + And the store has a payment method "PayPlug" with a code "payplug" and PayPlug Hosted Fields payment gateway + And This secret Key is valid + And the store ships everywhere for free + And the store has "DHL" shipping method with "$0.00" fee + And I am logged in as "john@bitbag.pl" + + @ui + Scenario: I can see and select the PayPlug payment method with Hosted Fields enabled + Given the store has a product "PHP T-Shirt" priced at "€50.00" + And I added product "PHP T-Shirt" to the cart + And I chose "DHL" shipping method + Then I should be on the checkout payment step + And I should be able to select "PayPlug" payment method + And I select "PayPlug" payment method + And I should see the "#card-container" element on the page diff --git a/grumphp.yml b/grumphp.yml index e8ea3a8c..ae81bef2 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -21,7 +21,10 @@ grumphp: configuration: 'ruleset/phpstan.neon' use_grumphp_paths: false memory_limit: '-1' - securitychecker_symfony: ~ + securitychecker_composeraudit: + # Abandoned packages are reported, not fatal: the offenders are transitive + # Sylius/Behat deps this plugin cannot replace. Advisories still fail the task. + abandoned: report yamllint: parse_custom_tags: true ecs: diff --git a/migrations/Version20260810120000.php b/migrations/Version20260810120000.php new file mode 100644 index 00000000..3da541cb --- /dev/null +++ b/migrations/Version20260810120000.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE payplug_upc_operation ( + id INT AUTO_INCREMENT NOT NULL, + order_id VARCHAR(255) NOT NULL, + operation_id VARCHAR(255) NOT NULL, + exec_code VARCHAR(255) NOT NULL, + outcome VARCHAR(255) NOT NULL, + amount INT NOT NULL, + treated TINYINT(1) NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE INDEX UNIQ_payplug_upc_operation_operation_id (operation_id), + INDEX idx_payplug_upc_operation_order_id (order_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE payplug_upc_operation'); + } +} diff --git a/migrations/Version20260901120000.php b/migrations/Version20260901120000.php new file mode 100644 index 00000000..65336e3c --- /dev/null +++ b/migrations/Version20260901120000.php @@ -0,0 +1,37 @@ +addSql('DELETE t1 FROM payplug_cards t1 INNER JOIN payplug_cards t2 ON t1.external_id = t2.external_id AND t1.is_live = t2.is_live AND t1.id > t2.id'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards (external_id, is_live)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX UNIQ_payplug_cards_external_id_is_live ON payplug_cards'); + } +} diff --git a/migrations/Version20260907140000.php b/migrations/Version20260907140000.php new file mode 100644 index 00000000..f7e37ce8 --- /dev/null +++ b/migrations/Version20260907140000.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + UPDATE sylius_gateway_config + SET config = JSON_REMOVE(config, '$.hfSubMerchantId') + WHERE factory_name = 'payplug' + AND JSON_CONTAINS_PATH(config, 'one', '$.hfSubMerchantId') + SQL); + } + + public function down(Schema $schema): void + { + // Deliberately not reversible: the removed value is a credential this migration does not + // retain, and no code version reads the key any more. Rolling the plugin back past + // PRE-3646 means re-entering it in the admin form, which is also the only way to obtain it. + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b01264ac..89387765 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,4 +18,10 @@ + + + + src + + diff --git a/ruleset/phpstan-baseline.neon b/ruleset/phpstan-baseline.neon index abca980f..76179e7d 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -665,14 +665,8 @@ parameters: path: ../src/Gateway/AbstractGatewayFactory.php - - message: '#^Cannot call method add\(\) on mixed\.$#' - identifier: method.nonObject - count: 1 - path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php - - - - message: '#^Cannot call method getId\(\) on mixed\.$#' - identifier: method.nonObject + message: '#^PHPDoc tag @SuppressWarnings has invalid value \(\(PHPMD\.UnusedFormalParameter\)\)\: Unexpected token "\.UnusedFormalParameter\)", expected ''\)'' at offset 554 on line 11$#' + identifier: phpDoc.parseError count: 1 path: ../src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -1252,18 +1246,6 @@ parameters: count: 1 path: ../src/Provider/PaymentTokenProvider.php - - - message: '#^Cannot access offset ''max_amount'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: ../src/Provider/SupportedMethodsProvider.php - - - - message: '#^Cannot access offset ''min_amount'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: ../src/Provider/SupportedMethodsProvider.php - - message: '#^PHPDoc tag @var with type Payum\\Core\\Model\\GatewayConfigInterface is not subtype of native type Sylius\\Component\\Payment\\Model\\GatewayConfigInterface\|null\.$#' identifier: varTag.nativeType diff --git a/src/Action/Admin/Auth/UnifiedAuthenticationController.php b/src/Action/Admin/Auth/UnifiedAuthenticationController.php index ef8efcc0..43bd0b2a 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -7,7 +7,10 @@ use Doctrine\ORM\EntityManagerInterface; use Payplug\Authentication; use Payplug\Payplug; +use PayPlug\SyliusPayPlugPlugin\Auth\IdTokenEmailExtractor; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; +use PayplugUnifiedCore\Auth\OAuth2Client; +use PayplugUnifiedCore\Contracts\IOAuthHttpClient; use Psr\Log\LoggerInterface; use Sylius\Resource\Doctrine\Persistence\RepositoryInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,7 +21,6 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; -use Symfony\Contracts\Cache\CacheInterface; /** * This controller is used to authenticate the user with PayPlug @@ -30,6 +32,9 @@ #[Route('/payplug/auth')] final class UnifiedAuthenticationController extends AbstractController { + // Matches the scope legacy Authentication::initiateOAuth() has always requested. + private const PKCE_SCOPE = 'openid offline profile email'; + /** * @param RepositoryInterface<\Sylius\Component\Core\Model\PaymentMethod> $paymentMethodRepository */ @@ -39,36 +44,34 @@ public function __construct( private EntityManagerInterface $entityManager, private PaymentMethodValidator $paymentMethodValidator, private LoggerInterface $logger, - private CacheInterface $cache, + private IOAuthHttpClient $oauthHttpClient, + private IdTokenEmailExtractor $idTokenEmailExtractor, + private string $payplugOauthBaseUrl, + private string $payplugOauthAudience, ) { } + private function buildOAuth2Client(string $redirectUri): OAuth2Client + { + return new OAuth2Client($this->oauthHttpClient, $this->payplugOauthBaseUrl, $redirectUri, self::PKCE_SCOPE, $this->payplugOauthAudience); + } + #[Route('/setup-redirection', name: 'payplug_sylius_admin_auth_setup_redirection')] public function setupRedirection(Request $request): Response { try { - $clientId = $request->query->get('client_id'); - $companyId = $request->query->get('company_id'); + $clientId = $request->query->getString('client_id'); + $companyId = $request->query->getString('company_id'); $request->getSession()->set('payplug_client_id', $clientId); $request->getSession()->set('payplug_company_id', $companyId); - $challenge = bin2hex(openssl_random_pseudo_bytes(50)); - $request->getSession()->set('payplug_oauth_challenge', $challenge); - $callBackUrl = $this->router->generate('payplug_sylius_admin_auth_oauth_callback', [], RouterInterface::ABSOLUTE_URL); + $authorizationRequest = $this->buildOAuth2Client($callBackUrl)->buildAuthorizationUrl($clientId); + $request->getSession()->set('payplug_oauth_state', $authorizationRequest->state); + $request->getSession()->set('payplug_oauth_code_verifier', $authorizationRequest->codeVerifier); - // This method will redirect the user to PayPlug's oauth page via header('Location')' - Authentication::initiateOAuth($clientId, $callBackUrl, $challenge); - // Fetch the header Location the Sdk put and redirect the user to it - $headers = \headers_list(); - foreach ($headers as $header) { - if (str_starts_with($header, 'Location:')) { - return new RedirectResponse(substr($header, 9)); - } - } - - throw new \LogicException('No location header found'); + return new RedirectResponse($authorizationRequest->url); } catch (\Throwable $e) { $this->logger->critical('Error while perform Payplug OAuth Setup redirection', ['message' => $e->getMessage(), 'exception' => $e]); @@ -81,16 +84,27 @@ public function oauthCallback(Request $request): Response { try { $code = $request->query->getString('code'); - /** @var string $clientId */ + $state = $request->query->getString('state'); $clientId = $request->getSession()->get('payplug_client_id'); - /** @var string $challenge */ - $challenge = $request->getSession()->get('payplug_oauth_challenge'); - $callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); + /** @var string $expectedState */ + $expectedState = $request->getSession()->get('payplug_oauth_state'); + $codeVerifier = $request->getSession()->get('payplug_oauth_code_verifier'); - $jwt = Authentication::generateJWTOneShot($code, $callback, $clientId, $challenge); - if ([] === $jwt || $jwt['httpStatus'] !== 200 || !\is_array($jwt['httpResponse'])) { - throw new BadRequestHttpException('Error while generating JWT'); + if ('' === $state || $state !== $expectedState) { + throw new BadRequestHttpException('OAuth state mismatch'); } + + if (!\is_string($clientId) || '' === $clientId) { + throw new BadRequestHttpException('OAuth client id missing from session'); + } + + if (!\is_string($codeVerifier) || '' === $codeVerifier) { + throw new BadRequestHttpException('OAuth code verifier missing from session'); + } + + $callback = $this->generateUrl('payplug_sylius_admin_auth_oauth_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); + $token = $this->buildOAuth2Client($callback)->exchangeAuthorizationCode($clientId, $code, $codeVerifier); + $paymentMethodId = $request->getSession()->get('payplug_sylius_oauth_payment_method_id'); if (null === $paymentMethodId) { throw new BadRequestHttpException('No payment method id found in session'); @@ -105,7 +119,7 @@ public function oauthCallback(Request $request): Response } $companyId = $request->getSession()->get('payplug_company_id'); - Payplug::init(['secretKey' => $jwt['httpResponse']['access_token']]); + Payplug::init(['secretKey' => $token->accessToken]); $clientName = 'Sylius - ' . $paymentMethod->getName(); $testClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'test'); $liveClientDataResult = Authentication::createClientIdAndSecret($companyId, $clientName, 'live'); @@ -113,18 +127,19 @@ public function oauthCallback(Request $request): Response $config = $gatewayConfig->getConfig(); $config['live_client'] = $liveClientDataResult['httpResponse'] ?? null; $config['test_client'] = $testClientDataResult['httpResponse'] ?? null; + // Who just authorized, for the admin screen to show. The id_token is the only carrier + // of that identity — /account has no email field and the client-credentials token used + // for every later API call names no user — and it is discarded with $token when this + // method returns, so it is captured here or not at all. Overwritten unconditionally, + // including with null: after a re-auth against a different PayPlug account, keeping the + // previous address would misreport which account is taking the money. + $config['account_email'] = $this->idTokenEmailExtractor->extract($token->idToken); $gatewayConfig->setConfig($config); $this->entityManager->flush(); $this->cleanSession($request); $request->getSession()->getFlashBag()->add('success', 'payplug_sylius_payplug_plugin.admin.oauth_callback_success'); - // Clean previous cached client config - $cacheKeyLive = sprintf('payplug_%s_api_key_live', $gatewayConfig->getFactoryName()); - $cacheKeyTest = sprintf('payplug_%s_api_key_test', $gatewayConfig->getFactoryName()); - $this->cache->delete($cacheKeyLive); - $this->cache->delete($cacheKeyTest); - // Ensure that the payment method is well configured $this->paymentMethodValidator->process($paymentMethod); @@ -152,7 +167,8 @@ private function cleanSession(Request $request): void $session = $request->getSession(); $session->remove('payplug_client_id'); $session->remove('payplug_company_id'); - $session->remove('payplug_oauth_challenge'); + $session->remove('payplug_oauth_state'); + $session->remove('payplug_oauth_code_verifier'); $session->remove('payplug_sylius_oauth_payment_method_id'); } } diff --git a/src/Action/Admin/Auth/UnifiedLogoutController.php b/src/Action/Admin/Auth/UnifiedLogoutController.php new file mode 100644 index 00000000..8c66f1cb --- /dev/null +++ b/src/Action/Admin/Auth/UnifiedLogoutController.php @@ -0,0 +1,126 @@ + would be invalid HTML. The CSRF token is therefore carried in the query string and + * checked here — it is the only thing between a crafted link and a merchant losing a connection. + */ +#[Route('/payplug/auth')] +final class UnifiedLogoutController extends AbstractController +{ + /** + * Per-payment-method so a token minted for one gateway cannot be replayed against another. + * Public because the template that renders the button mints the token with it. + */ + public const CSRF_TOKEN_ID_PREFIX = 'payplug_logout_'; + + /** + * @param RepositoryInterface<\Sylius\Component\Core\Model\PaymentMethod> $paymentMethodRepository + */ + public function __construct( + private RouterInterface $router, + private RepositoryInterface $paymentMethodRepository, + private GatewayConnectionRevoker $connectionRevoker, + private ?CsrfTokenManagerInterface $csrfTokenManager, + private LoggerInterface $logger, + ) { + } + + #[Route('/logout/{id}', name: 'payplug_sylius_admin_auth_logout', requirements: ['id' => '\d+'], methods: ['GET'])] + public function logout(Request $request, int $id): Response + { + $this->denyUnlessCsrfTokenIsValid($request, $id); + $paymentMethod = $this->findConnectedPayPlugPaymentMethod($id); + + try { + $this->connectionRevoker->revoke($paymentMethod); + $this->addFlashMessage($request, 'success', 'payplug_sylius_payplug_plugin.admin.logout_success'); + } catch (\Throwable $e) { + $this->logger->critical('Error while logging out the Payplug gateway', ['message' => $e->getMessage(), 'exception' => $e]); + $this->addFlashMessage($request, 'error', 'payplug_sylius_payplug_plugin.admin.logout_error'); + } + + return new RedirectResponse($this->router->generate('sylius_admin_payment_method_update', ['id' => $id])); + } + + /** + * SessionInterface makes no promise about flashes — only FlashBagAwareSessionInterface does — + * and the parent's addFlash() would reach for the container instead of the request in hand. + * A non-flash-aware session means a non-web context, where the message has no one to reach + * anyway, so it is dropped rather than raised. + */ + private function addFlashMessage(Request $request, string $type, string $message): void + { + $session = $request->getSession(); + + if ($session instanceof FlashBagAwareSessionInterface) { + $session->getFlashBag()->add($type, $message); + } + } + + /** + * A null token manager means the application runs with CSRF protection off — the service only + * exists while it is on, hence the optional injection in config/services.yaml. The check is + * then skipped rather than failed, matching Sylius's own admin actions that carry a token in + * the query string and guard it with sylius_csrf_protection_enabled(). + */ + private function denyUnlessCsrfTokenIsValid(Request $request, int $id): void + { + if (null === $this->csrfTokenManager) { + return; + } + + $token = new CsrfToken(self::CSRF_TOKEN_ID_PREFIX . $id, $request->query->getString('_csrf_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new BadRequestHttpException('Invalid CSRF token for the PayPlug logout request.'); + } + } + + /** + * 404 rather than a flash for both misses: the only legitimate source of this link is the + * connected-account block on a PayPlug gateway's own update screen, so an id that names + * nothing — or names another provider's payment method, which logout must never disable — + * is a tampered or stale URL, not a merchant mistake worth explaining. + */ + private function findConnectedPayPlugPaymentMethod(int $id): PaymentMethodInterface + { + $paymentMethod = $this->paymentMethodRepository->find($id); + + if ( + !$paymentMethod instanceof PaymentMethodInterface || + !str_contains((string) $paymentMethod->getGatewayConfig()?->getFactoryName(), 'payplug') + ) { + throw new NotFoundHttpException(sprintf('No PayPlug payment method found with id %d.', $id)); + } + + return $paymentMethod; + } +} diff --git a/src/Action/Admin/AuthorizedPaymentController.php b/src/Action/Admin/AuthorizedPaymentController.php new file mode 100644 index 00000000..4714c713 --- /dev/null +++ b/src/Action/Admin/AuthorizedPaymentController.php @@ -0,0 +1,222 @@ +handle($request, $orderId, $paymentId, 'capture', function (PaymentInterface $payment, ?int $amount, int $version): void { + $this->processor->capture($payment, $amount, $version); + }); + } + + public function cancel(Request $request, int $orderId, int $paymentId): Response + { + return $this->handle($request, $orderId, $paymentId, 'cancel', function (PaymentInterface $payment, ?int $amount, int $version): void { + $this->processor->cancel($payment, $amount, $version); + }); + } + + private function handle( + Request $request, + int $orderId, + int $paymentId, + string $action, + \Closure $operation, + ): Response { + if (!$this->authorizationChecker->isGranted(self::ADMIN_ROLE)) { + throw new AccessDeniedHttpException('Only an administrator may capture or cancel a payment.'); + } + + $this->denyUnlessCsrfTokenIsValid($request, $paymentId); + $payment = $this->findOrderPayment($orderId, $paymentId); + // Outside the try below: a malformed request is a 400, not something to swallow as an + // unexpected error. + $version = self::parseVersion($request); + + try { + $amount = $this->parseAmountFor($payment, $request->request->getString('amount'), $action); + // The processor flushes while it still holds its lock, see there. + $operation($payment, $amount, $version); + $this->addFlashMessage($request, 'success', self::FLASH_PREFIX . $action . '_success'); + } catch (AuthorizationOperationException $exception) { + $this->addFlashMessage($request, 'error', $exception->getTranslationKey(), $exception->getTranslationParameters()); + } catch (\Throwable $exception) { + // Anything but a refusal: typically the flush failing after the Unified API accepted the + // operation. The money may have moved without Sylius recording it, so the merchant is + // told to check at PayPlug before retrying (a retry would capture a second time) and + // a human is alerted. + $this->logger->critical('[PayPlug][UPC] Unexpected error during an authorization operation.', [ + 'sylius_payment_id' => $paymentId, + 'action' => $action, + 'exception' => $exception, + ]); + $this->addFlashMessage($request, 'error', self::FLASH_PREFIX . 'error.unexpected_error'); + } + + return new RedirectResponse($this->router->generate('sylius_admin_order_show', ['id' => $orderId])); + } + + /** + * Blank means "the whole remaining amount". Accepts a decimal comma as well as a dot, since + * that is what a French-speaking merchant types; anything else malformed — or longer than nine + * digits before the decimal point, which no real capture reaches and which would overflow — + * is sent on as an invalid amount (0) for the processor to refuse with its own explicit + * message. + * + * @throws AuthorizationOperationException when an amount is typed in a currency that + * AuthorizedPaymentOperationProcessor::supportsTypedAmounts() refuses + */ + public static function parseAmount(string $raw, string $currencyCode): ?int + { + $raw = \str_replace([' ', "\u{00A0}", ','], ['', '', '.'], \trim($raw)); + if ('' === $raw) { + return null; + } + + if (1 !== \preg_match('/^\d{1,9}(?:\.\d{1,2})?$/', $raw)) { + return 0; + } + + if (!AuthorizedPaymentOperationProcessor::supportsTypedAmounts($currencyCode)) { + throw new AuthorizationOperationException(self::FLASH_PREFIX . 'error.unsupported_currency', ['%currency%' => $currencyCode]); + } + + return AmountHelper::toCents((float) $raw, $currencyCode); + } + + /** + * @throws AuthorizationOperationException + */ + private function parseAmountFor(PaymentInterface $payment, string $raw, string $action): ?int + { + $currencyCode = (string) $payment->getCurrencyCode(); + + try { + return self::parseAmount($raw, $currencyCode); + } catch (AuthorizationOperationException $exception) { + $this->logger->warning('[PayPlug][UPC] Typed amount refused: its currency cannot be converted to the stored amounts.', [ + 'sylius_payment_id' => $payment->getId(), + 'action' => $action, + 'currency' => $currencyCode, + ]); + + throw $exception; + } + } + + /** + * The AuthorizationDetails::version() the form was built against. Mandatory here: it is the + * replay guard, and a hand-crafted POST must not be able to skip it. + */ + private static function parseVersion(Request $request): int + { + $version = $request->request->getString('version'); + + if (1 !== \preg_match('/^\d{1,9}$/', $version)) { + throw new BadRequestHttpException('Missing or invalid version for the PayPlug authorization request.'); + } + + return (int) $version; + } + + /** + * @param array $parameters + */ + private function addFlashMessage(Request $request, string $type, string $message, array $parameters = []): void + { + $session = $request->getSession(); + + if ($session instanceof FlashBagAwareSessionInterface) { + // Sylius's admin flash template translates a ['message', 'parameters'] pair itself. + $session->getFlashBag()->add($type, [] === $parameters ? $message : ['message' => $message, 'parameters' => $parameters]); + } + } + + /** + * See UnifiedLogoutController::denyUnlessCsrfTokenIsValid() for why a null token manager + * skips the check rather than failing it. + */ + private function denyUnlessCsrfTokenIsValid(Request $request, int $paymentId): void + { + if (null === $this->csrfTokenManager) { + return; + } + + $token = new CsrfToken(self::CSRF_TOKEN_ID_PREFIX . $paymentId, $request->request->getString('_csrf_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new BadRequestHttpException('Invalid CSRF token for the PayPlug authorization request.'); + } + } + + private function findOrderPayment(int $orderId, int $paymentId): PaymentInterface + { + $payment = $this->paymentRepository->find($paymentId); + + if (!$payment instanceof PaymentInterface || $payment->getOrder()?->getId() !== $orderId) { + throw new NotFoundHttpException(\sprintf('No payment %d found on order %d.', $paymentId, $orderId)); + } + + return $payment; + } +} diff --git a/src/Action/Api/ApiAwareTrait.php b/src/Action/Api/ApiAwareTrait.php index 2bab0e65..5c725017 100644 --- a/src/Action/Api/ApiAwareTrait.php +++ b/src/Action/Api/ApiAwareTrait.php @@ -7,11 +7,28 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use Payum\Core\Exception\UnsupportedApiException; +/** + * NOT deprecated as a whole: the $payPlugApiClient property below is still used by live code. + * {@see \PayPlug\SyliusPayPlugPlugin\Controller\OneClickAction}, a routed controller, uses this + * trait for that property alone and assigns it itself from PayPlugApiClientFactory. Only setApi() + * — the Payum ApiAwareInterface entry point — is dead. + */ trait ApiAwareTrait { /** @var PayPlugApiClientInterface */ protected $payPlugApiClient; + /** + * @deprecated Dead Payum wiring — never called at runtime. + * + * Payum assigns the API through this method when it builds a gateway, but no Payum gateway is + * ever registered for the `payplug*` factory names (config/services/gateway.xml, the only + * source of `payum.gateway_factory_builder` tags, is not loaded by the bundle extension). + * Live code obtains its client from PayPlugApiClientFactoryInterface::createForPaymentMethod() + * instead, which is channel-aware. + * + * Will be removed permanently in early 2027. + */ public function setApi($api): void { if (!$api instanceof PayPlugApiClientInterface) { diff --git a/src/Action/CaptureAction.php b/src/Action/CaptureAction.php index 8030246c..5cbe42a9 100644 --- a/src/Action/CaptureAction.php +++ b/src/Action/CaptureAction.php @@ -42,7 +42,18 @@ use Symfony\Contracts\Translation\TranslatorInterface; use Webmozart\Assert\Assert; -/** @deprecated */ +/** + * @deprecated Dead Payum wiring — this action is never executed. + * + * The `payum.action` tags declared below do reach the compiled container, but no Payum gateway is + * ever registered for the `payplug*` factory names: the only `payum.gateway_factory_builder` tags + * live in config/services/gateway.xml, which PayPlugSyliusPayPlugExtension::load() does not load. + * With no gateway to attach to, the tagged action is collected and then never run. + * + * Superseded by {@see \PayPlug\SyliusPayPlugPlugin\Command\Handler\CapturePaymentRequestHandler}. + * Kept only as an opt-in escape hatch for host applications importing gateway.xml themselves. + * Will be removed permanently in early 2027. + */ #[AutoconfigureTag( name: 'payum.action', attributes: [ diff --git a/src/Action/ConvertPaymentAction.php b/src/Action/ConvertPaymentAction.php index 663b9b1a..14394543 100644 --- a/src/Action/ConvertPaymentAction.php +++ b/src/Action/ConvertPaymentAction.php @@ -18,7 +18,19 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; -/** @deprecated */ +/** + * @deprecated Dead Payum wiring — this action is never executed. + * + * The `payum.action` tags declared below do reach the compiled container, but no Payum gateway is + * ever registered for the `payplug*` factory names: the only `payum.gateway_factory_builder` tags + * live in config/services/gateway.xml, which PayPlugSyliusPayPlugExtension::load() does not load. + * With no gateway to attach to, the tagged action is collected and then never run. + * + * The payment payload is now built by {@see \PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator}, + * called directly from {@see \PayPlug\SyliusPayPlugPlugin\Command\Handler\CapturePaymentRequestHandler}. + * Kept only as an opt-in escape hatch for host applications importing gateway.xml themselves. + * Will be removed permanently in early 2027. + */ #[AutoconfigureTag( name: 'payum.action', attributes: [ diff --git a/src/Action/NotifyAction.php b/src/Action/NotifyAction.php index e90dac54..8c4e510f 100644 --- a/src/Action/NotifyAction.php +++ b/src/Action/NotifyAction.php @@ -26,7 +26,21 @@ use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\DependencyInjection\Attribute\Autowire; -/** @deprecated */ +/** + * @deprecated Dead Payum wiring — this action is never executed. + * + * The `payum.action` tags declared below do reach the compiled container, but no Payum gateway is + * ever registered for the `payplug*` factory names: the only `payum.gateway_factory_builder` tags + * live in config/services/gateway.xml, which PayPlugSyliusPayPlugExtension::load() does not load. + * With no gateway to attach to, the tagged action is collected and then never run. + * + * The live IPN path is the routed controller {@see \PayPlug\SyliusPayPlugPlugin\Controller\IpnAction}, + * which dispatches to {@see \PayPlug\SyliusPayPlugPlugin\Command\Handler\NotifyPaymentRequestHandler}. + * Consequently the `sleep(10)` in execute() below is dead code and guards nothing in production. + * + * Kept only as an opt-in escape hatch for host applications importing gateway.xml themselves. + * Will be removed permanently in early 2027. + */ #[AutoconfigureTag( name: 'payum.action', attributes: [ diff --git a/src/Action/StatusAction.php b/src/Action/StatusAction.php index 135af4a6..9096fc20 100644 --- a/src/Action/StatusAction.php +++ b/src/Action/StatusAction.php @@ -28,7 +28,18 @@ use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\HttpFoundation\RequestStack; -/** @deprecated */ +/** + * @deprecated Dead Payum wiring — this action is never executed. + * + * The `payum.action` tags declared below do reach the compiled container, but no Payum gateway is + * ever registered for the `payplug*` factory names: the only `payum.gateway_factory_builder` tags + * live in config/services/gateway.xml, which PayPlugSyliusPayPlugExtension::load() does not load. + * With no gateway to attach to, the tagged action is collected and then never run. + * + * Superseded by {@see \PayPlug\SyliusPayPlugPlugin\Command\Handler\StatusPaymentRequestHandler}. + * Kept only as an opt-in escape hatch for host applications importing gateway.xml themselves. + * Will be removed permanently in early 2027. + */ #[AutoconfigureTag( name: 'payum.action', attributes: [ diff --git a/src/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 7d57cfe9..6e6f641d 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -4,22 +4,35 @@ namespace PayPlug\SyliusPayPlugPlugin\ApiClient; -use Payplug\Authentication; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; +use PayplugUnifiedCore\Auth\TokenManager; +use PayplugUnifiedCore\Exceptions\ApiException; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentMethodInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; final class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface { public function __construct( private RepositoryInterface $gatewayConfigRepository, private CacheInterface $cache, + private TokenManager $tokenManager, ) { } + /** + * Channel-ambiguous: since PRE-3628 several enabled gateway configs may share a factory name — + * one per channel — and findOneBy() then returns an arbitrary one of them, so the client this + * returns may carry another channel's account credentials. + * + * @internal Kept off {@see PayPlugApiClientFactoryInterface} so no application class can reach + * it; the sole remaining callers are the `payplug_sylius_payplug_plugin.api_client.*` + * service-factory definitions in config/services/client.xml, which are #[Autowire]d + * into seven services that have no payment method in scope. Use + * {@see self::createForPaymentMethod()} everywhere else. Removed once those + * singletons are made channel-aware — the open half of PRE-3682. + */ public function create(string $factoryName): PayPlugApiClientInterface { /** @var GatewayConfigInterface|null $gatewayConfig */ @@ -54,26 +67,17 @@ private function getTokenForGatewayConfig(GatewayConfigInterface $gatewayConfig) } /** @var array $clientConfig */ $clientConfig = $rawClientConfig; - $cacheKey = sprintf('payplug_%s_api_key_%s', $gatewayConfig->getFactoryName(), $isLive ? 'live' : 'test'); - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientConfig) { - $response = Authentication::generateJWT($clientConfig['client_id'] ?? '', $clientConfig['client_secret'] ?? ''); - if ([] === $response || !is_array($response['httpResponse'])) { - throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - } - - $accessToken = $response['httpResponse']['access_token']; - if (!is_string($accessToken)) { - throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); - } - $expiresIn = $response['httpResponse']['expires_in']; - if (!is_int($expiresIn)) { - $expiresIn = 200; - } - - $item->expiresAfter($expiresIn); + $clientId = $clientConfig['client_id'] ?? ''; + $clientSecret = $clientConfig['client_secret'] ?? ''; + if ('' === $clientId || '' === $clientSecret) { + throw new GatewayConfigurationException('No client config found for ' . $gatewayConfig->getFactoryName() . '. Please renew your credentials in the PayPlug plugin configuration.'); + } - return $accessToken; - }); + try { + return $this->tokenManager->getValidToken($clientId, $clientSecret); + } catch (ApiException $e) { + throw new GatewayConfigurationException('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.', 0, $e); + } } } diff --git a/src/ApiClient/PayPlugApiClientFactoryInterface.php b/src/ApiClient/PayPlugApiClientFactoryInterface.php index 12b96f2a..70cb1912 100644 --- a/src/ApiClient/PayPlugApiClientFactoryInterface.php +++ b/src/ApiClient/PayPlugApiClientFactoryInterface.php @@ -8,7 +8,12 @@ interface PayPlugApiClientFactoryInterface { - public function create(string $factoryName): PayPlugApiClientInterface; - + /** + * The only way to obtain a client from application code. Resolving one by factory name is + * deliberately absent: since PRE-3628 several enabled gateway configs may share a factory name + * — one per channel — so a name-based lookup returns an arbitrary one of them and can sign a + * request for channel A with channel B's account credentials. Keeping that signature off this + * interface makes the compiler, rather than review, the guard against reintroducing it. + */ public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface; } diff --git a/src/Auth/GatewayConnectionRevoker.php b/src/Auth/GatewayConnectionRevoker.php new file mode 100644 index 00000000..3d1fc8bd --- /dev/null +++ b/src/Auth/GatewayConnectionRevoker.php @@ -0,0 +1,102 @@ +getGatewayConfig() ?? throw new \LogicException( + 'The payment method has no gateway config, so no PayPlug connection can be revoked for it.', + ); + + $config = $gatewayConfig->getConfig(); + + // Read before the keys are dropped: the client id is the only handle on the cache entry. + // The cache is purged after the flush, so a failing flush cannot leave a gateway whose + // credentials still persist but whose tokens are gone. + $revokedCredentials = \array_map( + static fn (string $clientKey): mixed => $config[$clientKey] ?? null, + self::CLIENT_CREDENTIAL_KEYS, + ); + + foreach (self::CONNECTION_KEYS as $key) { + unset($config[$key]); + } + + // The Hosted Fields account id identifies the account that was just disconnected, so it is + // stale the moment the merchant reconnects to a different one. Cleared only where it is + // actually in use — on any other gateway, or on a CB gateway in redirected/integrated mode, + // it is an inert leftover the merchant may still want on their next login. + if (PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig)) { + unset($config[PayPlugGatewayFactory::HF_IDENTIFIER]); + } + + $gatewayConfig->setConfig($config); + $paymentMethod->disable(); + + $this->entityManager->flush(); + + foreach ($revokedCredentials as $clientCredentials) { + $this->forgetCachedToken($clientCredentials); + } + } + + private function forgetCachedToken(mixed $clientCredentials): void + { + $clientId = \is_array($clientCredentials) ? ($clientCredentials['client_id'] ?? null) : null; + + if (!\is_string($clientId) || '' === $clientId) { + return; + } + + $this->tokenCache->delete(self::TOKEN_CACHE_KEY_PREFIX . $clientId); + } +} diff --git a/src/Auth/IdTokenEmailExtractor.php b/src/Auth/IdTokenEmailExtractor.php new file mode 100644 index 00000000..d52c6b8a --- /dev/null +++ b/src/Auth/IdTokenEmailExtractor.php @@ -0,0 +1,105 @@ +decodeClaims($idToken); + + $email = $claims['email'] ?? null; + + if (!\is_string($email) || false === filter_var($email, \FILTER_VALIDATE_EMAIL)) { + return null; + } + + return $email; + } + + /** + * @return array The token's claims, or an empty array for anything unreadable + */ + private function decodeClaims(?string $idToken): array + { + $payload = $this->decodePayloadSegment($idToken); + + if (null === $payload) { + return []; + } + + $claims = json_decode($payload, true); + + return \is_array($claims) ? $claims : []; + } + + /** + * @return string|null The decoded payload segment, or null when the token is absent, empty or + * not the three dot-separated segments a JWT is made of + */ + private function decodePayloadSegment(?string $idToken): ?string + { + if (null === $idToken || '' === $idToken) { + return null; + } + + $segments = explode('.', $idToken); + + if (self::SEGMENT_COUNT !== \count($segments)) { + return null; + } + + return $this->base64UrlDecode($segments[self::PAYLOAD_SEGMENT]); + } + + /** + * JWT segments use base64url (RFC 7515 §2): `-`/`_` in place of `+`/`/`, and no `=` padding. + * Feeding one straight to base64_decode() silently corrupts any payload containing those + * characters, which real id tokens routinely do. + */ + private function base64UrlDecode(string $segment): ?string + { + $padded = str_pad(strtr($segment, '-_', '+/'), (int) (ceil(\strlen($segment) / 4) * 4), '='); + + $decoded = base64_decode($padded, true); + + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Auth/SyliusOAuthHttpClient.php b/src/Auth/SyliusOAuthHttpClient.php new file mode 100644 index 00000000..36611f3d --- /dev/null +++ b/src/Auth/SyliusOAuthHttpClient.php @@ -0,0 +1,46 @@ + $formParams + * @param array $headers + * + * @return array{status: int, body: string} + */ + public function post(string $url, array $formParams, array $headers = []): array + { + try { + $response = $this->httpClient->request('POST', $url, [ + 'body' => http_build_query($formParams), + 'headers' => $headers, + ]); + + return [ + 'status' => $response->getStatusCode(), + // false = don't throw on non-2xx; OAuth2Client itself checks the status. + 'body' => $response->getContent(false), + ]; + } catch (TransportExceptionInterface $e) { + // Network-level failure (DNS, timeout, connection reset) — getStatusCode()/getContent() + // throw this regardless of the `false` above, since it only suppresses HTTP status + // exceptions, not transport ones. Status 0 makes OAuth2Client::requestToken() throw its + // own ApiException, which callers (e.g. PayPlugApiClientFactory) already catch and + // translate, the same way a non-2xx response from PayPlug itself would be handled. + return ['status' => 0, 'body' => $e->getMessage()]; + } + } +} diff --git a/src/Auth/SyliusTokenCache.php b/src/Auth/SyliusTokenCache.php new file mode 100644 index 00000000..1be872a8 --- /dev/null +++ b/src/Auth/SyliusTokenCache.php @@ -0,0 +1,48 @@ +cache->getItem($this->sanitizeKey($key)); + + if (!$item->isHit()) { + return null; + } + + $value = $item->get(); + + return \is_string($value) ? $value : null; + } + + public function set(string $key, string $value, int $ttlSeconds): void + { + $item = $this->cache->getItem($this->sanitizeKey($key)); + $item->set($value); + $item->expiresAfter($ttlSeconds); + $this->cache->save($item); + } + + public function delete(string $key): void + { + $this->cache->deleteItem($this->sanitizeKey($key)); + } + + // PSR-6 rejects "{}()/\@:" in cache keys; TokenManager's keys contain ":". + private function sanitizeKey(string $key): string + { + return (string) preg_replace('/[{}()\/\\\\@:]/', '_', $key); + } +} diff --git a/src/Checker/GatewayChannelConflictChecker.php b/src/Checker/GatewayChannelConflictChecker.php new file mode 100644 index 00000000..57f4be14 --- /dev/null +++ b/src/Checker/GatewayChannelConflictChecker.php @@ -0,0 +1,128 @@ + + */ + public function findConflicts(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + $channelCodes = $this->channelCodes($paymentMethod); + + if (!$paymentMethod->isEnabled() || [] === $channelCodes) { + return []; + } + + return \array_values(\array_filter( + $this->claims($paymentMethod, $factoryName), + static fn (string $channelCode): bool => \in_array($channelCode, $channelCodes, true), + \ARRAY_FILTER_USE_KEY, + )); + } + + /** + * The channels the gateway channel picker must render as unselectable, keyed by channel code. + * + * Deliberately unconditional where `findConflicts()` returns early on the subject's `enabled` + * flag: the picker is rendered before the admin has decided anything. Building both answers + * from the same `claims()` lookup is what keeps the disabled options and the submit-time rule + * in step. + * + * The subject's own channels are excluded, however. A browser does not submit a disabled + * checkbox, so disabling one that is *checked* would silently drop that channel the next time + * the admin saves — the very removal this picker exists to prevent. An overlap that already + * exists in the data therefore stays selectable and is reported by `findConflicts()` on submit + * instead. `claims()` alone cannot cover this: it excludes the subject by id, which says + * nothing about a *different* enabled payment method holding a channel the subject also holds. + * + * @return array + */ + public function findClaimedChannels(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + return \array_diff_key( + \array_map( + static fn (array $claim): PaymentMethodInterface => $claim['paymentMethod'], + $this->claims($paymentMethod, $factoryName), + ), + \array_flip($this->channelCodes($paymentMethod)), + ); + } + + /** + * Channels held by *other* enabled payment methods of the same factory. + * + * Keying by channel code collapses the case of two enabled rivals holding the same channel down + * to a single claim, so neither caller reports it twice. PRE-3628 makes that state hard to + * reach, not impossible — see {@see \PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict} + * for the write paths the rule does not cover — so the collapsing is load-bearing, not defensive. + * + * @return array + */ + private function claims(PaymentMethodInterface $paymentMethod, string $factoryName): array + { + $claims = []; + foreach ($this->paymentMethodRepository->findEnabledByGatewayName($factoryName) as $rival) { + if ($this->isSamePaymentMethod($paymentMethod, $rival) || !$rival->isEnabled()) { + continue; + } + + foreach ($rival->getChannels() as $rivalChannel) { + if (!$rivalChannel instanceof ChannelInterface) { + continue; + } + + $channelCode = $rivalChannel->getCode(); + + if (null !== $channelCode) { + $claims[$channelCode] = ['channel' => $rivalChannel, 'paymentMethod' => $rival]; + } + } + } + + return $claims; + } + + /** + * @return list + */ + private function channelCodes(PaymentMethodInterface $paymentMethod): array + { + $channelCodes = []; + foreach ($paymentMethod->getChannels() as $channel) { + $channelCode = $channel->getCode(); + if (null !== $channelCode) { + $channelCodes[] = $channelCode; + } + } + + return $channelCodes; + } + + /** + * On creation the subject has no id yet, so it can never match a persisted rival. + */ + private function isSamePaymentMethod(PaymentMethodInterface $subject, PaymentMethodInterface $rival): bool + { + return null !== $subject->getId() && $subject->getId() === $rival->getId(); + } +} diff --git a/src/Command/CaptureAliasPaymentRequest.php b/src/Command/CaptureAliasPaymentRequest.php new file mode 100644 index 00000000..665bbabe --- /dev/null +++ b/src/Command/CaptureAliasPaymentRequest.php @@ -0,0 +1,9 @@ +paymentRequestProvider->provide($captureAliasPaymentRequest); + /** @var PaymentInterface $payment */ + $payment = $paymentRequest->getPayment(); + + try { + $method = $this->contextBuilder->resolvePaymentMethod($payment); + + $card = $this->selectedCardResolver->resolve(); + if (null === $card) { + throw new \LogicException('No saved card alias selected for the payment.'); + } + [$amount, $currencyCode] = $this->contextBuilder->resolveAmountAndCurrency($payment); + $accountId = $this->contextBuilder->resolveGatewayCredentials($method); + + $order = $this->assertCardBelongsToOrder($card, $payment->getOrder(), $method); + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $paymentRequest, $order); + $dto = $this->buildPaymentDto($common, $card, $order); + + $output = $this->unifiedApiPaymentCreator->createPayment($dto, $method); + } catch (ApiException | InvalidPaymentException | \LogicException $e) { + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias); + + return; + } + + $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); + $createdDetails = [ + ...$payment->getDetails(), + 'alias_id' => $card->getExternalId(), + 'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + ...$hostedFieldsIds, + ]; + // Before applyOutcome() below — see CaptureHostedPaymentRequestHandler's own comment. + $payment->setDetails($common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amount)); + $this->outcomeApplier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Alias, $hostedFieldsIds); + + $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); + + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + } + + private function assertCardBelongsToOrder( + Card $card, + ?OrderInterface $order, + PaymentMethodInterface $method, + ): OrderInterface { + if (null === $order || $card->getCustomer() !== $order->getCustomer() || $card->getPaymentMethod() !== $method) { + throw new \LogicException('Selected card does not belong to the paying customer or payment method.'); + } + + return $order; + } + + private function buildPaymentDto(CommonFieldsDto $common, Card $card, OrderInterface $order): PaymentDto + { + $customerDto = $this->contextBuilder->buildCustomerDto($order); + $browserDto = $this->contextBuilder->buildBrowserDto(); + + $fullName = $this->contextBuilder->resolveFullNameForCardDetails($order); + $paymentMethod = null !== $fullName + ? ['details' => ['fullName' => $fullName]] + : null; + + return new PaymentDto($common, $card->getExternalId(), 'ONE_CLICK', $browserDto, $customerDto, $paymentMethod); + } +} diff --git a/src/Command/Handler/CaptureHostedPaymentRequestHandler.php b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php new file mode 100644 index 00000000..d7c6f1d8 --- /dev/null +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -0,0 +1,195 @@ +paymentRequestProvider->provide($captureHostedPaymentRequest); + /** @var PaymentInterface $payment */ + $payment = $paymentRequest->getPayment(); + $details = $payment->getDetails(); + + try { + $method = $this->contextBuilder->resolvePaymentMethod($payment); + $hfToken = $this->resolveHostedFieldsToken($details); + $amountAndCurrency = $this->contextBuilder->resolveAmountAndCurrency($payment); + $accountId = $this->contextBuilder->resolveGatewayCredentials($method); + + $dto = $this->buildHostedFieldDto( + $paymentRequest, + $payment, + $details, + $hfToken, + $amountAndCurrency, + $accountId, + ); + + $this->logger->debug('[PayPlug debug] Unified API hosted payment request payload.', [ + 'payload' => $dto->createPayloadBody(), + ]); + + $output = $this->unifiedApiPaymentCreator->createPayment($dto, $method); + } catch (ApiException | InvalidHostedFieldException | \LogicException $e) { + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Hosted); + + return; + } + + $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); + $createdDetails = [ + ...$details, + 'hosted_fields_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + ...$hostedFieldsIds, + ]; + // Before applyOutcome() below, which reads this back to apply "authorize" rather than + // "complete" for an authorization-only payment. + $payment->setDetails($dto->common->capture ? $createdDetails : AuthorizationDetails::open($createdDetails, $output, $amountAndCurrency[0])); + $this->outcomeApplier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Hosted, $hostedFieldsIds); + + $this->outcomeApplier->applyOutcome($paymentRequest, $payment, $output); + + $saveCard = true === ($details['hosted_fields_save_card'] ?? false); + if ($saveCard && null !== $output->aliasId) { + $unifiedApiOperationId = $hostedFieldsIds['hosted_fields_operation_id'] ?? null; + $fetchedCardData = null !== $unifiedApiOperationId ? $this->fetchCardDataFromUnifiedApi($unifiedApiOperationId, $method) : []; + $this->cardPersister->persist($output->aliasId, $payment, $method, $details, $fetchedCardData); + } + + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + } + + /** @param mixed[] $details */ + private function resolveHostedFieldsToken(array $details): string + { + $hfToken = $details['hosted_fields_token'] ?? null; + if (!\is_string($hfToken) || '' === $hfToken) { + throw new \LogicException('No hosted fields token found on the payment.'); + } + + return $hfToken; + } + + /** + * @param mixed[] $details + * @param array{0: int, 1: string} $amountAndCurrency + */ + private function buildHostedFieldDto( + PaymentRequestInterface $paymentRequest, + PaymentInterface $payment, + array $details, + string $hfToken, + array $amountAndCurrency, + string $accountId, + ): HostedFieldDto { + [$amount, $currencyCode] = $amountAndCurrency; + + $order = $payment->getOrder(); + + $common = $this->contextBuilder->buildCommonFields($accountId, $amount, $currencyCode, $paymentRequest, $order); + + $selectedBrand = $details['hosted_fields_selected_brand'] ?? null; + $hasSelectedBrand = \is_string($selectedBrand) && '' !== $selectedBrand; + $fullName = $this->contextBuilder->resolveFullNameForCardDetails($order); + $hasFullName = null !== $fullName; + + $saveCard = true === ($details['hosted_fields_save_card'] ?? false); + $recurringMode = $saveCard ? 'ONE_CLICK' : null; + + $cardDetails = []; + if ($hasFullName) { + $cardDetails['fullName'] = $fullName; + } + if ($hasSelectedBrand) { + $cardDetails['selectedBrand'] = $selectedBrand; + } + + // saveFutureUsage is only requested alongside a fullName: the Unified API rejects an + // alias-creation request (paymentMethod.saveFutureUsage: true) missing + // paymentMethod.details.fullName, so requesting it without one would fail the entire + // payment rather than just skip saving the card. + /** @var array{details?: array{fullName?: string, selectedBrand?: string}, saveFutureUsage?: bool}|null $paymentMethodDetails */ + $paymentMethodDetails = match (true) { + [] !== $cardDetails && $saveCard && $hasFullName => ['details' => $cardDetails, 'saveFutureUsage' => true], + [] !== $cardDetails => ['details' => $cardDetails], + default => null, + }; + + // Named arguments here (rather than positional) because HostedFieldDto's real + // constructor interposes $recurringMode between $hfToken and $browser/$customer — + // positional args would silently misalign. + return new HostedFieldDto( + $common, + $hfToken, + recurringMode: $recurringMode, + browser: $this->contextBuilder->buildBrowserDto(), + customer: $this->contextBuilder->buildCustomerDto($order), + paymentMethod: $paymentMethodDetails, + ); + } + + /** + * The Unified API's alias-creation response carries only the alias id, no card metadata — the + * card's real brand/last4/expiration only show up on the operation resource, fetched here via + * the existing OperationStatusFetcherInterface (already used by StatusHostedPaymentRequestHandler + * for its 3DS polling fallback) rather than a second, duplicate client. Best-effort: any + * failure (API error, unexpected shape) is logged and swallowed rather than propagated, since + * this only enriches the card being persisted — PayplugCardPersister::persist()'s own + * $details-based fallback values already cover the case where this fetch fails or a field + * turns out to be absent. + * + * @return array{aliasId?: string, brand?: string, last4?: string, expirationMonth?: int, expirationYear?: int} + */ + private function fetchCardDataFromUnifiedApi(string $operationId, PaymentMethodInterface $method): array + { + try { + $response = $this->operationStatusFetcher->getOperation($operationId, $method); + } catch (ApiException $e) { + $this->logger->error('[PayPlug][UPC] Failed to fetch operation for card metadata.', [ + 'unified_api_operation_id' => $operationId, + 'error' => $e->getMessage(), + ]); + + return []; + } + + return CardDataFromPaymentMethodExtractor::extract($response['body']); + } +} diff --git a/src/Command/Handler/NotifyHostedPaymentRequestHandler.php b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php new file mode 100644 index 00000000..0b46fbf6 --- /dev/null +++ b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php @@ -0,0 +1,147 @@ +paymentRequestProvider->provide($notifyHostedPaymentRequest); + $payload = $paymentRequest->getPayload(); + $rawBody = $payload['http_request']['content'] ?? null; // @phpstan-ignore-line + $rawHeaders = $payload['http_request']['headers'] ?? null; // @phpstan-ignore-line + if (!\is_string($rawBody) || !\is_array($rawHeaders)) { + throw new \LogicException('Invalid UPC notification payload.'); + } + $headers = $this->flattenHeaders($rawHeaders); // @phpstan-ignore-line + + $lockKey = 'payplug_upc_notify_' . \hash('sha256', $rawBody); + if (!$this->lock->acquire($lockKey, 30)) { + // Another delivery of the same webhook is already being processed; tell PayPlug it + // succeeded so it does not keep retrying. + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + return; + } + + try { + // Per-gateway-config shared secret: verify against the account this payment belongs to. + $method = $paymentRequest->getPayment()->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('The payment has no payment method, so no webhook secret can be resolved for it.'); + } + + $expectedHeader = $this->configurationRepository->forPaymentMethod($method)->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; + $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader); + + if (!$this->matchesPaymentRequest($paymentRequest, $operationData)) { + return; + } + + if ($this->paymentRepository->isTreated($operationData->operationId)) { + return; + } + + $this->paymentRepository->save($operationData); + // This per-request notification only ever confirms the payment's own creation, so an + // authorization-only payment's success is "authorized", never "paid". + $outcome = AuthorizationDetails::fromDetails($paymentRequest->getPayment()->getDetails())->resolveCreationOutcome($operationData->outcome); + $this->orderStateMutator->apply($operationData->orderId, $outcome); + $this->paymentRepository->markTreated($operationData->operationId); + + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + } catch (InvalidNotificationException $e) { + $this->logger->error('[PayPlug][UPC] Rejected webhook notification.', ['error' => $e->getMessage()]); + $paymentRequest->setResponseData(['error' => $e->getMessage()]); + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + } finally { + $this->lock->release($lockKey); + } + } + + // Split out of __invoke() to keep its own return count within SonarCloud's limit (php:S1142) + // — both branches here mean "nothing to apply," they just differ in whether that's expected + // (still-pending) or a problem worth logging and failing the request over (mismatch). + private function matchesPaymentRequest(PaymentRequestInterface $paymentRequest, OperationData $operationData): bool + { + if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) { + // Not a final outcome — leave the payment request as-is and, crucially, do not + // touch isTreated()/markTreated(): a later, final notification for this same + // operation must still be free to apply once it arrives. Mirrors the same guard + // in HostedFieldsWebhookNotificationHandler::treat(). + return false; + } + + $expectedOrderId = (string) $paymentRequest->getPayment()->getId(); // @phpstan-ignore-line + $expectedAmount = $paymentRequest->getPayment()->getAmount(); + if ($operationData->orderId !== $expectedOrderId || $operationData->amount !== $expectedAmount) { + $this->logger->error('[PayPlug][UPC] Webhook notification does not match the payment request it was received for.', [ + 'expected_order_id' => $expectedOrderId, + 'received_order_id' => $operationData->orderId, + 'expected_amount' => $expectedAmount, + 'received_amount' => $operationData->amount, + ]); + $paymentRequest->setResponseData(['error' => 'Webhook notification does not match the expected payment.']); + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + return false; + } + + return true; + } + + /** + * @param array> $rawHeaders + * + * @return array + */ + private function flattenHeaders(array $rawHeaders): array + { + $headers = []; + foreach ($rawHeaders as $name => $values) { + $headers[$name] = $values[0] ?? ''; + } + + return $headers; + } +} diff --git a/src/Command/Handler/StatusHostedPaymentRequestHandler.php b/src/Command/Handler/StatusHostedPaymentRequestHandler.php new file mode 100644 index 00000000..4b4e6ea7 --- /dev/null +++ b/src/Command/Handler/StatusHostedPaymentRequestHandler.php @@ -0,0 +1,141 @@ +paymentRequestProvider->provide($statusHostedPaymentRequest); + /** @var PaymentInterface $payment */ + $payment = $paymentRequest->getPayment(); + + if (self::FORCED_STATUS_CANCELED === $statusHostedPaymentRequest->getForcedStatus()) { + if ($this->stateMachine->can($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)) { + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); + } + } else { + $this->pollForOutcomeIfStillPending($payment); + } + + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + } + + /** + * Fallback for when the asynchronous webhook (NotifyHostedPaymentRequestHandler / + * HostedFieldsWebhookNotificationHandler) hasn't confirmed the outcome by the time the + * customer is bounced back from the 3DS challenge page — a GET against the Unified API's + * public operation endpoint, per PayPlug's own documented recommendation for a delayed or + * lost webhook. Skipped entirely once anything else has already resolved the payment, so + * there's nothing here to double-apply. + * + * The public operation endpoint's response is the same webhook-shaped payload + * WebhookNotificationHelper::parse() already knows how to read (id/execCode/orderId/amount + * at the top level), so applying it is unconditionally delegated to + * HostedFieldsWebhookNotificationHandler::treat() — same cross-check against the payment, + * same isTreated()/markTreated() dedupe, same persistence, as the real webhook path, + * including its own no-op when the fetched operation is still THREE_DS_PENDING. This handler + * no longer needs its own copy of that pending-code check. + */ + private function pollForOutcomeIfStillPending(PaymentInterface $payment): void + { + $operationId = self::resolveOperationId($payment->getDetails()); + $method = $payment->getMethod(); + + // One guard rather than three: there is nothing to poll unless the payment is still + // pending, carries an operation id and names the method whose account owns it. + if ( + \in_array($payment->getState(), self::RESOLVED_STATES, true) || + null === $operationId || + !$method instanceof PaymentMethodInterface + ) { + return; + } + + $body = $this->fetchOperationBody($payment, $operationId, $method); + + if (null === $body) { + return; + } + + try { + $this->webhookNotificationHandler->treat($payment, $body, []); + } catch (InvalidNotificationException $e) { + $this->logger->error('[PayPlug][UPC] Hosted payment status poll returned a payload that could not be applied.', [ + 'sylius_payment_id' => $payment->getId(), + 'hosted_fields_operation_id' => $operationId, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * A failed poll is logged and swallowed rather than raised: it is a fallback for a webhook that + * has not arrived *yet*, so leaving the payment where it is lets the webhook — or the + * UpdatePaymentStateCommand — resolve it later. + */ + private function fetchOperationBody( + PaymentInterface $payment, + string $operationId, + PaymentMethodInterface $method, + ): ?string { + try { + return $this->operationStatusFetcher->getOperation($operationId, $method)['body']; + } catch (ApiException $e) { + $this->logger->error('[PayPlug][UPC] Hosted payment status poll failed.', [ + 'sylius_payment_id' => $payment->getId(), + 'hosted_fields_operation_id' => $operationId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** @param mixed[] $details */ + private static function resolveOperationId(array $details): ?string + { + $operationId = $details['hosted_fields_operation_id'] ?? null; + + return \is_string($operationId) && '' !== $operationId ? $operationId : null; + } +} diff --git a/src/Command/NotifyHostedPaymentRequest.php b/src/Command/NotifyHostedPaymentRequest.php new file mode 100644 index 00000000..8a110a78 --- /dev/null +++ b/src/Command/NotifyHostedPaymentRequest.php @@ -0,0 +1,9 @@ +getAction() === PaymentRequestInterface::ACTION_CAPTURE; + } + + public function provide(PaymentRequestInterface $paymentRequest): object + { + $details = $paymentRequest->getPayment()->getDetails(); + $selectedCard = $this->selectedCardResolver->resolve(); + + if ($this->isAlreadyInFlight($details, $selectedCard)) { + return new OfflineCapturePaymentRequest($paymentRequest->getId()); + } + + return null !== $selectedCard + ? new CaptureAliasPaymentRequest($paymentRequest->getId()) + : new CaptureHostedPaymentRequest($paymentRequest->getId()); + } + + /** + * True when a capture attempt for this exact payment/card pair was already created — either + * via createPayment() (e.g. the shopper returned to /pay after a 3DS redirect) or as an + * already-in-flight alias attempt for the same card. + * + * @param mixed[] $details + */ + private function isAlreadyInFlight(array $details, ?Card $selectedCard): bool + { + if (\is_string($details['hosted_fields_created_at'] ?? null)) { + return true; + } + + if (\is_string($details['alias_payment_created_at'] ?? null)) { + return $this->isAliasAttemptStillInFlight($details, $selectedCard); + } + + return false; + } + + // Split out of isAlreadyInFlight() to keep its own return count within SonarCloud's limit + // (php:S1142). + private function isAliasAttemptStillInFlight(array $details, ?Card $selectedCard): bool + { + // The customer switching to a *different, currently selected* saved card after an + // earlier alias attempt was created for another one is NOT "in flight" — the caller + // must fall through and re-evaluate as a fresh attempt, rather than silently + // re-polling the stale one. But switching to "other"/no selection at all gives no new + // card to justify a fresh attempt, so the still-unresolved earlier alias attempt stays + // in flight rather than dispatching a second, independent capture for the same + // payment (double-charge risk if the abandoned attempt later resolves too). + if (null === $selectedCard) { + return true; + } + + return $selectedCard->getExternalId() === ($details['alias_id'] ?? null); + } +} diff --git a/src/Command/Provider/CapturePaymentRequestCommandProvider.php b/src/Command/Provider/CapturePaymentRequestCommandProvider.php index bb507e7e..61411803 100644 --- a/src/Command/Provider/CapturePaymentRequestCommandProvider.php +++ b/src/Command/Provider/CapturePaymentRequestCommandProvider.php @@ -10,6 +10,7 @@ use Sylius\Bundle\PaymentBundle\CommandProvider\PaymentRequestCommandProviderInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; +use Symfony\Component\DependencyInjection\Attribute\Autowire; #[AutoconfigureTag( 'payplug_sylius_payplug_plugin.command_provider.payplug', @@ -41,6 +42,14 @@ )] final class CapturePaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { + use DelegatesToHostedFieldsCommandProviderTrait; + + public function __construct( + #[Autowire(service: CaptureHostedPaymentRequestCommandProvider::class)] + private PaymentRequestCommandProviderInterface $hostedFieldsCommandProvider, + ) { + } + public function supports(PaymentRequestInterface $paymentRequest): bool { return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE; @@ -48,6 +57,11 @@ public function supports(PaymentRequestInterface $paymentRequest): bool public function provide(PaymentRequestInterface $paymentRequest): object { + $hostedFieldsResult = $this->delegateToHostedFieldsCommandProvider($paymentRequest, $this->hostedFieldsCommandProvider); + if (null !== $hostedFieldsResult) { + return $hostedFieldsResult; + } + if ($this->isAlreadyCreated($paymentRequest)) { // The payment has already been created, let's use the offline capture request to be redirected to the thank-you page return new OfflineCapturePaymentRequest($paymentRequest->getId()); diff --git a/src/Command/Provider/DelegatesToHostedFieldsCommandProviderTrait.php b/src/Command/Provider/DelegatesToHostedFieldsCommandProviderTrait.php new file mode 100644 index 00000000..3232d6cc --- /dev/null +++ b/src/Command/Provider/DelegatesToHostedFieldsCommandProviderTrait.php @@ -0,0 +1,29 @@ +getPayment()->getMethod()?->getGatewayConfig())) { + return null; + } + + return $hostedFieldsCommandProvider->provide($paymentRequest); + } +} diff --git a/src/Command/Provider/NotifyHostedPaymentRequestCommandProvider.php b/src/Command/Provider/NotifyHostedPaymentRequestCommandProvider.php new file mode 100644 index 00000000..f16d1340 --- /dev/null +++ b/src/Command/Provider/NotifyHostedPaymentRequestCommandProvider.php @@ -0,0 +1,27 @@ +getAction() === PaymentRequestInterface::ACTION_NOTIFY; + } + + public function provide(PaymentRequestInterface $paymentRequest): object + { + return new NotifyHostedPaymentRequest($paymentRequest->getId()); + } +} diff --git a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php index 4ef91b6a..11deb9df 100644 --- a/src/Command/Provider/NotifyPaymentRequestCommandProvider.php +++ b/src/Command/Provider/NotifyPaymentRequestCommandProvider.php @@ -8,6 +8,7 @@ use Sylius\Bundle\PaymentBundle\CommandProvider\PaymentRequestCommandProviderInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; +use Symfony\Component\DependencyInjection\Attribute\Autowire; #[AutoconfigureTag( 'payplug_sylius_payplug_plugin.command_provider.payplug', @@ -39,6 +40,14 @@ )] final class NotifyPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { + use DelegatesToHostedFieldsCommandProviderTrait; + + public function __construct( + #[Autowire(service: NotifyHostedPaymentRequestCommandProvider::class)] + private PaymentRequestCommandProviderInterface $hostedFieldsCommandProvider, + ) { + } + public function supports(PaymentRequestInterface $paymentRequest): bool { return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_NOTIFY; @@ -46,6 +55,8 @@ public function supports(PaymentRequestInterface $paymentRequest): bool public function provide(PaymentRequestInterface $paymentRequest): object { - return new NotifyPaymentRequest($paymentRequest->getId()); + $hostedFieldsResult = $this->delegateToHostedFieldsCommandProvider($paymentRequest, $this->hostedFieldsCommandProvider); + + return $hostedFieldsResult ?? new NotifyPaymentRequest($paymentRequest->getId()); } } diff --git a/src/Command/Provider/StatusHostedPaymentRequestCommandProvider.php b/src/Command/Provider/StatusHostedPaymentRequestCommandProvider.php new file mode 100644 index 00000000..9f79b89b --- /dev/null +++ b/src/Command/Provider/StatusHostedPaymentRequestCommandProvider.php @@ -0,0 +1,37 @@ +getAction() === PaymentRequestInterface::ACTION_STATUS; + } + + public function provide(PaymentRequestInterface $paymentRequest): object + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return new StatusHostedPaymentRequest($paymentRequest->getId()); + } + + return new StatusHostedPaymentRequest($paymentRequest->getId(), $request->query->getString('status')); + } +} diff --git a/src/Command/Provider/StatusPaymentRequestCommandProvider.php b/src/Command/Provider/StatusPaymentRequestCommandProvider.php index 37676e9b..fd39c7f2 100644 --- a/src/Command/Provider/StatusPaymentRequestCommandProvider.php +++ b/src/Command/Provider/StatusPaymentRequestCommandProvider.php @@ -8,6 +8,7 @@ use Sylius\Bundle\PaymentBundle\CommandProvider\PaymentRequestCommandProviderInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\RequestStack; #[AutoconfigureTag( @@ -40,8 +41,13 @@ )] final class StatusPaymentRequestCommandProvider implements PaymentRequestCommandProviderInterface { - public function __construct(private RequestStack $requestStack) - { + use DelegatesToHostedFieldsCommandProviderTrait; + + public function __construct( + private RequestStack $requestStack, + #[Autowire(service: StatusHostedPaymentRequestCommandProvider::class)] + private PaymentRequestCommandProviderInterface $hostedFieldsCommandProvider, + ) { } public function supports(PaymentRequestInterface $paymentRequest): bool @@ -51,6 +57,11 @@ public function supports(PaymentRequestInterface $paymentRequest): bool public function provide(PaymentRequestInterface $paymentRequest): object { + $hostedFieldsResult = $this->delegateToHostedFieldsCommandProvider($paymentRequest, $this->hostedFieldsCommandProvider); + if (null !== $hostedFieldsResult) { + return $hostedFieldsResult; + } + $request = $this->requestStack->getCurrentRequest(); if (null === $request) { return new StatusPaymentRequest($paymentRequest->getId()); diff --git a/src/Command/StatusHostedPaymentRequest.php b/src/Command/StatusHostedPaymentRequest.php new file mode 100644 index 00000000..9a33facf --- /dev/null +++ b/src/Command/StatusHostedPaymentRequest.php @@ -0,0 +1,18 @@ +forcedStatus; + } +} diff --git a/src/Controller/CardController.php b/src/Controller/CardController.php index d26770e0..5b3167e1 100644 --- a/src/Controller/CardController.php +++ b/src/Controller/CardController.php @@ -6,14 +6,13 @@ use Doctrine\Persistence\ManagerRegistry; use Payplug\Exception\NotFoundException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Entity\Card; use PayPlug\SyliusPayPlugPlugin\Entity\CardsOwnerInterface; use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Customer\Context\CustomerContextInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; @@ -27,8 +26,7 @@ public function __construct( private CustomerContextInterface $customerContext, private EntityRepository $payplugCardRepository, private TranslatorInterface $translator, - #[Autowire('@payplug_sylius_payplug_plugin.api_client.payplug')] - private PayPlugApiClientInterface $payPlugApiClient, + private PayPlugApiClientFactoryInterface $apiClientFactory, private RequestStack $requestStack, private ManagerRegistry $managerRegistry, ) { @@ -73,7 +71,12 @@ public function deleteAction(int $id): Response $cardToken = $card->getExternalId(); try { - $this->payPlugApiClient->deleteCard($cardToken); + // Signed with the account the card was saved on, not an arbitrary CB config: PayPlug + // card tokens are account-scoped, and since PRE-3628 a customer may hold cards from + // several channels, each on a different account. Resolving from the ambient channel + // context would be wrong for exactly that reason — a Sylius customer spans channels, + // so the card's own payment method is the only correct source. + $this->apiClientFactory->createForPaymentMethod($card->getPaymentMethod())->deleteCard($cardToken); } catch (NotFoundException) { $this->requestStack->getSession()->getFlashBag()->add('error', $this->translator->trans('payplug_sylius_payplug_plugin.ui.account.saved_cards.deleted_error')); diff --git a/src/Controller/IntegratedPaymentController.php b/src/Controller/IntegratedPaymentController.php index a5b53925..caaeeb43 100644 --- a/src/Controller/IntegratedPaymentController.php +++ b/src/Controller/IntegratedPaymentController.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Controller; use Doctrine\ORM\EntityManagerInterface; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; @@ -35,7 +35,7 @@ public function __construct( private RepositoryInterface $paymentMethodRepository, private OrderRepositoryInterface $orderRepository, private PayPlugPaymentDataCreator $paymentDataCreator, - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, private EntityManagerInterface $entityManager, private LoggerInterface $logger, ) { @@ -52,11 +52,6 @@ public function __construct( #[Route(path: '/{_locale}/payplug/integrated-payment/init/{paymentMethodId}', name: 'payplug_sylius_integrated_payment_init', methods: ['GET', 'POST'])] public function initPaymentAction(Request $request, int $paymentMethodId): Response { - $paymentMethod = $this->paymentMethodRepository->find($paymentMethodId); - if (!$paymentMethod instanceof PaymentMethodInterface) { - throw $this->createNotFoundException(); - } - $order = null; if (\is_string($orderToken = $request->query->get('orderToken'))) { $order = $this->orderRepository->findOneByTokenValue($orderToken); @@ -73,18 +68,17 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo throw $this->createNotFoundException('No payment available'); } + // The id is shopper-supplied, so it is resolved against the order before anything is + // done with it — see resolvePaymentMethod(). + $paymentMethod = $this->resolvePaymentMethod($paymentMethodId, $order); $payment->setMethod($paymentMethod); - $factoryName = $paymentMethod->getGatewayConfig()?->getFactoryName(); - if (PayPlugGatewayFactory::FACTORY_NAME !== $factoryName) { - throw new BadRequestHttpException('Unsupported payment method of Integrated Payment'); - } $paymentData = $this->paymentDataCreator->create($payment); // Mandatory $paymentData['integration'] = PayPlugApiClientInterface::INTEGRATED_PAYMENT_INTEGRATION; $this->logger->debug('Payplug Payment data for creation', $paymentData->getArrayCopy()); - $apiClient = $this->apiClientFactory->create($factoryName); + $apiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod); $payplugPayment = $apiClient->createPayment($paymentData->getArrayCopy()); $this->logger->debug('PayPlug payment created', (array) $payplugPayment); @@ -98,4 +92,60 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo 'payment_id' => $payplugPayment->id, ], Response::HTTP_CREATED); } + + /** + * Resolves the shopper-supplied payment method id, refusing anything the order could not + * legitimately be paid with. + * + * The channel check is the load-bearing one. Since PRE-3628 several CB gateway configs may + * coexist, one per channel, each connected to a different PayPlug account, and + * createForPaymentMethod() faithfully resolves whichever account this id names. Without it a + * shopper checking out on channel A could pass channel B's payment method id and have the + * payment created on merchant B's account, while the IPN — resolving the same method — verifies + * against B's key and marks order A paid. + */ + private function resolvePaymentMethod(int $paymentMethodId, OrderInterface $order): PaymentMethodInterface + { + $paymentMethod = $this->paymentMethodRepository->find($paymentMethodId); + + if (!$paymentMethod instanceof PaymentMethodInterface) { + throw $this->createNotFoundException(); + } + + if (PayPlugGatewayFactory::FACTORY_NAME !== $paymentMethod->getGatewayConfig()?->getFactoryName()) { + throw new BadRequestHttpException('Unsupported payment method of Integrated Payment'); + } + + if (!$paymentMethod->isEnabled()) { + throw new BadRequestHttpException('The payment method is not enabled.'); + } + + if (!$this->isAvailableOnChannelOf($paymentMethod, $order)) { + throw new BadRequestHttpException('The payment method is not available on this channel.'); + } + + return $paymentMethod; + } + + /** + * Matched on channel code rather than object identity, the same way + * GatewayChannelConflictChecker does: the order and the payment method may carry channel + * instances from different unit-of-work states. + */ + private function isAvailableOnChannelOf(PaymentMethodInterface $paymentMethod, OrderInterface $order): bool + { + $channelCode = $order->getChannel()?->getCode(); + + if (null === $channelCode) { + return false; + } + + foreach ($paymentMethod->getChannels() as $channel) { + if ($channelCode === $channel->getCode()) { + return true; + } + } + + return false; + } } diff --git a/src/Controller/IpnAction.php b/src/Controller/IpnAction.php index ee7ca7c5..ec4709e0 100644 --- a/src/Controller/IpnAction.php +++ b/src/Controller/IpnAction.php @@ -27,7 +27,21 @@ use Symfony\Component\Routing\Attribute\Route; use Webmozart\Assert\Assert; -/** @deprecated */ +/** + * @deprecated Legacy Payum-era static webhook receiver for the non-Unified-API (SDK-based) + * gateways — Oney, Bancontact, Apple Pay, and the legacy card flow. Superseded by + * Sylius's native per-payment-method notify mechanism: NotifyPaymentProvider and + * NotifyRefundPaymentProvider (both #[AsNotifyPaymentProvider]) already route these + * gateways' real notifications through sylius_payment_method_notify + * (/payment-methods/{code}) instead, via the notification_url PayPlugPaymentDataCreator + * sends at payment-creation time. Unified API traffic (Hosted Fields and any future + * UPC-backed method) never used this branch — see UnifiedApiIpnAction instead, which + * needs its own fixed, parameter-less URL since PayPlug's Unified API notifier + * Receiver is configured once per merchant in Cockpit and cannot target a + * per-payment-method route. Kept, rather than deleted outright, until it's confirmed + * no already-onboarded merchant's account still has a webhook pointed at this route + * for the legacy flow. + */ #[AsController] class IpnAction { @@ -76,7 +90,7 @@ public function __invoke(Request $request): JsonResponse if ( !$paymentMethod->getGatewayConfig() instanceof GatewayConfigInterface || - !\in_array($factoryName = $paymentMethod->getGatewayConfig()->getFactoryName(), [ + !\in_array($paymentMethod->getGatewayConfig()->getFactoryName(), [ PayPlugGatewayFactory::FACTORY_NAME, OneyGatewayFactory::FACTORY_NAME, BancontactGatewayFactory::FACTORY_NAME, @@ -86,7 +100,7 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(null, Response::HTTP_UNAUTHORIZED); } - $this->payPlugApiClient = $this->apiClientFactory->create($factoryName); + $this->payPlugApiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod); try { $resource = $this->payPlugApiClient->treat($input); diff --git a/src/Controller/OneClickAction.php b/src/Controller/OneClickAction.php index a1e27ab2..9128e584 100644 --- a/src/Controller/OneClickAction.php +++ b/src/Controller/OneClickAction.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Controller; use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use Payum\Core\ApiAwareInterface; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; @@ -33,7 +33,7 @@ class OneClickAction extends AbstractController implements GatewayAwareInterface public function __construct( private PaymentRepositoryInterface $paymentRepository, private Payum $payum, - private PayPlugApiClientFactory $payPlugApiClientFactory, + private PayPlugApiClientFactoryInterface $payPlugApiClientFactory, ) { } diff --git a/src/Controller/UnifiedApiIpnAction.php b/src/Controller/UnifiedApiIpnAction.php new file mode 100644 index 00000000..f87b9d8e --- /dev/null +++ b/src/Controller/UnifiedApiIpnAction.php @@ -0,0 +1,137 @@ +getContent(); + $payment = $this->resolveHostedFieldsPayment($input); + + if (null === $payment) { + return new JsonResponse(null, Response::HTTP_UNAUTHORIZED); + } + + try { + $this->hostedFieldsWebhookNotificationHandler->treat($payment, $input, self::flattenHeaders($request->headers->all())); + } catch (InvalidNotificationException $exception) { + $this->logger->error('[PayPlug][UPC] Rejected webhook notification.', ['error' => $exception->getMessage()]); + } + + return new JsonResponse(); + } + + // Split out of __invoke() to keep its own return count within SonarCloud's limit (php:S1142) + // — collapses the three "reject this notification" conditions (missing id, unknown payment, + // wrong gateway) into a single null-or-not result. + private function resolveHostedFieldsPayment(string $input): ?PaymentInterface + { + $content = json_decode($input, true); + $id = \is_array($content) ? ($content['id'] ?? null) : null; + if (!\is_string($id) || '' === $id) { + // if we are too fast canceling a payment before we got an answer from PayPlug gateway + return null; + } + + $payment = $this->findPaymentWithRetry($id); + if (null === $payment) { + return null; + } + + $paymentMethod = $payment->getMethod(); + Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); + $gateway = $paymentMethod->getGatewayConfig(); + Assert::isInstanceOf($gateway, GatewayConfigInterface::class); + + // Defensive: this route is dedicated to Unified API traffic, so a resolved payment that + // isn't actually on a Unified API-backed config (currently, only Hosted Fields) is + // rejected rather than guessed at. Update this check if/when a second Unified API-backed + // payment method is added. + return PayPlugGatewayFactory::isHostedFieldsConfig($gateway) ? $payment : null; + } + + private function findPaymentWithRetry(string $id): ?PaymentInterface + { + for ($attempt = 1; $attempt <= self::PAYMENT_RESOLUTION_MAX_ATTEMPTS; ++$attempt) { + $payment = $this->paymentRepository->findOneByPayPlugPaymentId($id); + if (null !== $payment) { + return $payment; + } + + if ($attempt < self::PAYMENT_RESOLUTION_MAX_ATTEMPTS) { + usleep(self::PAYMENT_RESOLUTION_RETRY_DELAY_MICROSECONDS); + } + } + + return null; + } + + /** + * @param array> $rawHeaders + * + * @return array + */ + private static function flattenHeaders(array $rawHeaders): array + { + $headers = []; + foreach ($rawHeaders as $name => $values) { + $headers[$name] = $values[0] ?? ''; + } + + return $headers; + } +} diff --git a/src/Creator/PayPlugPaymentDataCreator.php b/src/Creator/PayPlugPaymentDataCreator.php index 9b437e68..7b7000df 100644 --- a/src/Creator/PayPlugPaymentDataCreator.php +++ b/src/Creator/PayPlugPaymentDataCreator.php @@ -21,6 +21,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Upc\CustomerTitleResolver; use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -37,8 +38,6 @@ class PayPlugPaymentDataCreator private const DELIVERY_TYPE_NEW = 'NEW'; - private const PAYPLUG_CARD_ID_OTHER = 'other'; - public function __construct( private CanSaveCardCheckerInterface $canSaveCardChecker, private RepositoryInterface $payplugCardRepository, @@ -142,9 +141,9 @@ public function formatNumber(string $phoneNumber, ?string $isoCode): array private function formatTitle(CustomerInterface $customer): ?string { - $gender = $customer->getGender(); + $title = CustomerTitleResolver::resolve($customer->getGender()); - return 'm' === $gender ? 'mr' : ('f' === $gender ? 'mrs' : null); + return null !== $title ? strtolower($title) : null; } private function formatLanguageCode(?string $languageCode): ?string @@ -252,7 +251,7 @@ private function alterPayPlugDetailsForOneClick( $cardId = $this->requestStack->getSession()->get('payplug_payment_method'); if ( - (null === $cardId || self::PAYPLUG_CARD_ID_OTHER === $cardId) && $this->canSaveCardChecker->isAllowed( + (null === $cardId || PayPlugGatewayFactory::CARD_CHOICE_OTHER === $cardId) && $this->canSaveCardChecker->isAllowed( $paymentMethod, ) ) { diff --git a/src/Entity/Card.php b/src/Entity/Card.php index 147bdaeb..2f749b3c 100644 --- a/src/Entity/Card.php +++ b/src/Entity/Card.php @@ -17,6 +17,7 @@ */ #[ORM\Entity] #[ORM\Table(name: 'payplug_cards')] +#[ORM\UniqueConstraint(name: 'UNIQ_payplug_cards_external_id_is_live', columns: ['external_id', 'is_live'])] class Card implements ResourceInterface { /** diff --git a/src/Entity/PayPlugOperation.php b/src/Entity/PayPlugOperation.php new file mode 100644 index 00000000..912f2b34 --- /dev/null +++ b/src/Entity/PayPlugOperation.php @@ -0,0 +1,145 @@ +orderId = $orderId; + $this->operationId = $operationId; + $this->execCode = $execCode; + $this->outcome = $outcome; + $this->amount = $amount; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getOrderId(): string + { + return $this->orderId; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getExecCode(): string + { + return $this->execCode; + } + + public function getOutcome(): string + { + return $this->outcome; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function isTreated(): bool + { + return $this->treated; + } + + public function markTreated(): void + { + $this->treated = true; + } + + public function toOperationData(): OperationData + { + return new OperationData($this->operationId, $this->execCode, $this->outcome, $this->amount, $this->orderId); + } +} diff --git a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php index 9991e0b3..d633b4b1 100644 --- a/src/EventSubscriber/PostPaymentSelectEventSubscriber.php +++ b/src/EventSubscriber/PostPaymentSelectEventSubscriber.php @@ -5,6 +5,9 @@ namespace PayPlug\SyliusPayPlugPlugin\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsCaptureData; +use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\HostedFieldsPaymentProcessorInterface; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent; use Sylius\Component\Core\Model\OrderInterface; @@ -21,30 +24,60 @@ final class PostPaymentSelectEventSubscriber implements EventSubscriberInterface { private const CHECKOUT_ROUTE = 'sylius_shop_checkout_select_payment'; - private const UPDATE_ORDER_PAYMENT_ROUTE = 'sylius_shop_order_show'; - private const TOKEN_FIELD = 'payplug_integrated_payment_token'; + private const HOSTED_FIELDS_TOKEN_FIELD = 'hostedfields_token'; + + private const HOSTED_FIELDS_SELECTED_BRAND_FIELD = 'hostedfields_selected_brand'; + + private const HOSTED_FIELDS_SAVE_CARD_FIELD = 'hostedfields_save_card'; + + private const HOSTED_FIELDS_LAST4_FIELD = 'hostedfields_last4'; + + private const HOSTED_FIELDS_EXP_MONTH_FIELD = 'hostedfields_exp_month'; + + private const HOSTED_FIELDS_EXP_YEAR_FIELD = 'hostedfields_exp_year'; + + private const HOSTED_FIELDS_COUNTRY_FIELD = 'hostedfields_country'; + public function __construct( private RequestStack $requestStack, private EntityManagerInterface $entityManager, private StateMachineInterface $stateMachine, + private HostedFieldsPaymentProcessorInterface $hostedFieldsPaymentProcessor, ) { } public static function getSubscribedEvents(): array { return [ - RequestEvent::class => 'alterRequestConfigurationForIntegratedPayment', + RequestEvent::class => 'alterRequestConfigurationForInlineCardCapture', 'sylius.order.post_payment' => 'handle', 'sylius.order.post_update' => 'handle', ]; } - public function alterRequestConfigurationForIntegratedPayment(RequestEvent $event): void + /** + * Both inline card-capture modes force the checkout to TRANSITION_COMPLETE inside + * `sylius.order.post_payment` (see handle()), so a `redirect` entry MUST be injected here: + * Sylius's CheckoutRedirectListener listens to that same event and bails out only when + * `_sylius['redirect']` is set. Without it, it would resolve a route for the `completed` + * checkout state, which has no entry in `sylius_shop.checkout_resolver.route_map` + * (RouteNotFoundException). + * + * Both Integrated Payment and Hosted Fields target `sylius_shop_order_pay` (Payum + * capture/status for Integrated Payment; for Hosted Fields, the same `payplug`-tagged + * Capture/Notify/StatusPaymentRequestCommandProvider trio delegates to their + * Hosted-Fields-specific counterparts — see PayPlugGatewayFactory::isHostedFieldsConfig() — + * so the payment is actually created/confirmed through UPC. + */ + public function alterRequestConfigurationForInlineCardCapture(RequestEvent $event): void { $request = $event->getRequest(); - if (!$this->hasToken($request) || self::CHECKOUT_ROUTE !== $request->attributes->get('_route')) { + if ( + (!$this->hasToken($request) && !$this->hasHostedFieldsToken($request)) || + self::CHECKOUT_ROUTE !== $request->attributes->get('_route') + ) { return; } if (!$request->attributes->has('_sylius')) { @@ -71,7 +104,7 @@ public function handle(ResourceControllerEvent $resourceControllerEvent): void return; } - if (!\in_array($request->attributes->get('_route'), [self::CHECKOUT_ROUTE, self::UPDATE_ORDER_PAYMENT_ROUTE], true)) { + if (self::CHECKOUT_ROUTE !== $request->attributes->get('_route')) { return; } @@ -82,6 +115,12 @@ public function handle(ResourceControllerEvent $resourceControllerEvent): void return; } + if ($this->hasHostedFieldsToken($request)) { + $this->handleHostedFieldsToken($request, $lastPayment); + + return; + } + if (!$this->hasToken($request)) { return; } @@ -128,6 +167,63 @@ private function getToken(Request $request): string return $token; } + private function hasHostedFieldsToken(Request $request): bool + { + if (!$request->request->has(self::HOSTED_FIELDS_TOKEN_FIELD)) { + return false; + } + + return '' !== $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); + } + + private function handleHostedFieldsToken(Request $request, PaymentInterface $lastPayment): void + { + // Guard against a crafted POST completing checkout through this path for a payment + // method that does not actually have Hosted Fields enabled. + if (!$this->isHostedFieldsEnabled($lastPayment)) { + return; + } + + $hfToken = $this->getRequestField($request, self::HOSTED_FIELDS_TOKEN_FIELD); + $selectedBrand = $this->getRequestField($request, self::HOSTED_FIELDS_SELECTED_BRAND_FIELD); + $saveCard = 'true' === $request->request->get(self::HOSTED_FIELDS_SAVE_CARD_FIELD, 'false'); + $last4 = $this->getRequestField($request, self::HOSTED_FIELDS_LAST4_FIELD); + $expirationMonth = $this->getOptionalIntRequestField($request, self::HOSTED_FIELDS_EXP_MONTH_FIELD); + $expirationYear = $this->getOptionalIntRequestField($request, self::HOSTED_FIELDS_EXP_YEAR_FIELD); + $countryCode = $this->getRequestField($request, self::HOSTED_FIELDS_COUNTRY_FIELD); + + $this->hostedFieldsPaymentProcessor->process( + $lastPayment, + new HostedFieldsCaptureData($hfToken, $selectedBrand, $saveCard, $last4, $expirationMonth, $expirationYear, $countryCode), + ); + + $this->applyToComplete($lastPayment->getOrder() ?? throw new \LogicException('Order not found for payment')); + } + + private function isHostedFieldsEnabled(PaymentInterface $payment): bool + { + return PayPlugGatewayFactory::isHostedFieldsConfig($payment->getMethod()?->getGatewayConfig()); + } + + private function getRequestField(Request $request, string $field): string + { + $value = $request->request->get($field, ''); + Assert::string($value); + + return $value; + } + + /** + * Distinguishes a genuinely absent field from a legitimately-fetched 0, unlike a plain + * (int) cast on the empty-string default, which would collapse both to the same value. + */ + private function getOptionalIntRequestField(Request $request, string $field): ?int + { + $value = $this->getRequestField($request, $field); + + return '' === $value ? null : (int) $value; + } + private function applyToComplete(OrderInterface $order): void { if ($this->stateMachine->can($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE)) { diff --git a/src/Exception/Payment/AuthorizationOperationException.php b/src/Exception/Payment/AuthorizationOperationException.php new file mode 100644 index 00000000..1f7aed60 --- /dev/null +++ b/src/Exception/Payment/AuthorizationOperationException.php @@ -0,0 +1,38 @@ + $translationParameters + */ + public function __construct( + private string $translationKey, + private array $translationParameters = [], + ?\Throwable $previous = null, + ) { + parent::__construct($translationKey, 0, $previous); + } + + public function getTranslationKey(): string + { + return $this->translationKey; + } + + /** + * @return array + */ + public function getTranslationParameters(): array + { + return $this->translationParameters; + } +} diff --git a/src/Gateway/AbstractGatewayFactory.php b/src/Gateway/AbstractGatewayFactory.php index 8d7f4d11..e743a202 100644 --- a/src/Gateway/AbstractGatewayFactory.php +++ b/src/Gateway/AbstractGatewayFactory.php @@ -17,6 +17,18 @@ abstract class AbstractGatewayFactory extends GatewayFactory public const BASE_CURRENCY_CODE = 'EUR'; + /** + * @deprecated Never called — this class only extends Payum's GatewayFactory for historical + * reasons. populateConfig() runs when Payum builds a gateway, and no Payum gateway + * is ever registered for the `payplug*` factory names: the only + * `payum.gateway_factory_builder` tags live in config/services/gateway.xml, which + * PayPlugSyliusPayPlugExtension::load() does not load. `payum.api` and + * `payum.http_client` are therefore never assigned. + * + * The class itself is NOT deprecated — it remains the home of FACTORY_NAME, + * FACTORY_TITLE and BASE_CURRENCY_CODE, referenced throughout the plugin. + * Will be removed permanently in early 2027. + */ protected function populateConfig(ArrayObject $config): void { $config->defaults([ diff --git a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php index a6c47a2a..0a981b72 100644 --- a/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php +++ b/src/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtension.php @@ -9,12 +9,20 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Contracts\Translation\TranslatorInterface; final class PayPlugGatewayConfigurationTypeExtension extends AbstractTypeExtension { + public function __construct(private TranslatorInterface $translator) + { + } + /** * @inheritdoc */ @@ -29,12 +37,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help_html' => true, 'required' => false, ]) - ->add(PayPlugGatewayFactory::INTEGRATED_PAYMENT, CheckboxType::class, [ - 'block_name' => 'payplug_checkbox', - 'label' => 'payplug_sylius_payplug_plugin.form.integrated_payment_enable', - 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, - 'required' => false, - ]) ->add(PayPlugGatewayFactory::DEFERRED_CAPTURE, CheckboxType::class, [ 'block_name' => 'payplug_checkbox', 'label' => 'payplug_sylius_payplug_plugin.form.deferred_capture_enable', @@ -43,6 +45,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help_html' => true, 'required' => false, ]) + ->add(PayPlugGatewayFactory::DISPLAY_MODE_FIELD, ChoiceType::class, [ + 'mapped' => false, + 'required' => false, + 'expanded' => true, + 'placeholder' => 'payplug_sylius_payplug_plugin.form.redirected_payment_enable', + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + 'choices' => [ + 'payplug_sylius_payplug_plugin.form.integrated_payment_enable' => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + 'payplug_sylius_payplug_plugin.ui.hosted_fields_option' => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + ], + ]) + ->add(PayPlugGatewayFactory::HF_IDENTIFIER, TextType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.hf_identifier_label', + 'required' => false, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + ]) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $data = $event->getData(); // phpstan check @@ -52,6 +70,45 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $data['payum.http_client'] = '@payplug_sylius_payplug_plugin.api_client.payplug'; $event->setData($data); }) + // DISPLAY_MODE_FIELD's pre-selection must happen on POST_SET_DATA, not PRE_SET_DATA: + // it's `mapped => false`, and Symfony's DataMapper::mapDataToForms() runs right after + // PRE_SET_DATA dispatches (as part of the same parent setData() call), resetting every + // unmapped child back to its configured (null) default — silently wiping out a + // setData() call made from PRE_SET_DATA. POST_SET_DATA fires after that reset, so + // nothing overwrites it afterward. + ->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { + $data = $event->getData(); + if (!is_array($data)) { + return; + } + + $event->getForm()->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->setData( + PayPlugGatewayFactory::resolveDisplayMode($data), + ); + }) + ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $form = $event->getForm(); + $submittedData = [ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => $form->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(), + PayPlugGatewayFactory::HF_IDENTIFIER => $form->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getData(), + ]; + + foreach (PayPlugGatewayFactory::missingHostedFieldsRequirements($submittedData) as $field) { + $form->get($field)->addError(new FormError( + $this->translator->trans('payplug_sylius_payplug_plugin.form.account_id_required'), + )); + } + }) + ->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + $data = $event->getData(); + if (!is_array($data)) { + return; + } + + $displayMode = $event->getForm()->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(); + $displayMode = is_string($displayMode) ? $displayMode : null; + $event->setData(array_merge($data, PayPlugGatewayFactory::resolveDisplayModeFlags($displayMode))); + }) ; } diff --git a/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php new file mode 100644 index 00000000..b6e00330 --- /dev/null +++ b/src/Gateway/Form/Extension/PaymentMethodTypeExtension.php @@ -0,0 +1,277 @@ +addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { + $form = $event->getForm(); + $paymentMethod = $this->resolvePayPlugPaymentMethod($event->getData()); + + if (null === $paymentMethod || null === $this->resolveConfigurationType($form)) { + return; + } + + /** @var GatewayConfigInterface $gatewayConfig */ + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + $this->disableClaimedChannelChoices($form, $paymentMethod, (string) $gatewayConfig->getFactoryName()); + }); + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $form = $event->getForm(); + $configurationType = $this->resolveConfigurationType($form); + $paymentMethod = $this->resolvePayPlugPaymentMethod($event->getData()); + + if (null === $configurationType || null === $paymentMethod) { + return; + } + + /** @var GatewayConfigInterface $gatewayConfig */ + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + $this->addChannelConflictErrors($form, $paymentMethod, (string) $gatewayConfig->getFactoryName()); + $this->addBaseCurrencyErrors($form, $paymentMethod, $gatewayConfig, $configurationType); + }); + } + + public static function getExtendedTypes(): iterable + { + return [PaymentMethodType::class]; + } + + /** + * Returns the submitted payment method only when it carries a gateway config with a factory + * name; the listener pairs this with `resolveConfigurationType()` to decide whether the + * payment method is one of ours. + */ + private function resolvePayPlugPaymentMethod(mixed $data): ?PaymentMethodInterface + { + if (!$data instanceof PaymentMethodInterface) { + return null; + } + + $gatewayConfig = $data->getGatewayConfig(); + + $isPayPlugPaymentMethod = $gatewayConfig instanceof GatewayConfigInterface && + null !== $gatewayConfig->getFactoryName(); + + return $isPayPlugPaymentMethod ? $data : null; + } + + /** + * Resolving to non-null is what makes a submitted payment method "one of ours": the decision + * rests on the gateway configuration form's own type rather than on a hardcoded factory-name + * list, since every PayPlug gateway configuration type extends `AbstractGatewayConfigurationType` + * and nothing else does, so the test stays exact when an eighth gateway is added. + * + * `GatewayConfigType` only adds the `config` child when the factory has a registered + * configuration type, hence the `has()` guards. + * + * The per-gateway currency policy is read back off the configuration type instance rather than + * duplicated into a registry here: it already lives one-class-per-gateway, and only the CB type + * narrows it (to Integrated Payment). Form types are stateless services, so calling their + * public hooks is safe. + */ + private function resolveConfigurationType(FormInterface $form): ?AbstractGatewayConfigurationType + { + if (!$form->has('gatewayConfig') || !$form->get('gatewayConfig')->has('config')) { + return null; + } + + $configurationType = $form->get('gatewayConfig')->get('config')->getConfig()->getType()->getInnerType(); + + return $configurationType instanceof AbstractGatewayConfigurationType ? $configurationType : null; + } + + /** + * PRE-3629: render the channels another enabled gateway of the same factory already holds as + * unselectable, instead of letting the admin pick one only to be refused on submit. + * + * The field belongs to Sylius' own `CoreBundle` type extension, so it cannot be configured from + * our `buildForm()` — extension ordering between the two is not guaranteed. POST_SET_DATA on the + * root form is the first safe point: `GatewayConfigType` adds its `config` child during its own + * PRE_SET_DATA, so at root PRE_SET_DATA `resolveConfigurationType()` would still find nothing, + * whereas by root POST_SET_DATA every child is built and populated. Replacing a child there is + * also still data-mapped (`Form::add()` only skips mapping while `lockSetData` is on, i.e. + * during PRE_SET_DATA). + * + * A resolved form config cannot be mutated, hence the replace-with-copied-options approach. + * `data` is `setDefined` rather than defaulted on `FormType`, so it is absent from the copied + * options unless someone deliberately set it — in which case carrying it over is the correct + * thing to do anyway. + */ + private function disableClaimedChannelChoices( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + string $factoryName, + ): void { + if (!$form->has('channels')) { + return; + } + + $claimedChannels = $this->conflictChecker->findClaimedChannels($paymentMethod, $factoryName); + + if ([] === $claimedChannels) { + return; + } + + $form->add('channels', ChannelChoiceType::class, \array_merge( + $form->get('channels')->getConfig()->getOptions(), + [ + 'choice_attr' => fn (ChannelInterface $channel): array => $this->channelChoiceAttributes( + $channel, + $claimedChannels, + ), + ], + )); + } + + /** + * A channel the payment method being edited already holds is never reported as claimed (see + * `GatewayChannelConflictChecker::findClaimedChannels()`), and that is deliberate: browsers do + * not submit disabled checkboxes, so disabling a *checked* one would silently drop the channel + * on save. Those are left selectable and reported by the POST_SUBMIT rule instead. + * + * `disabled => true` is rendered as `disabled="disabled"` by form_div_layout's `attributes` + * block, which every Sylius form theme inherits. That block also pipes `title` through `|trans` + * against the *field's* translation domain — harmless here, since re-translating an already + * translated sentence finds no catalogue entry and Symfony hands the string straight back. The + * message can't be left as a bare key for Twig to translate instead: `attr_translation_parameters` + * is set per field, not per choice, and each channel has to name a different payment method. + * + * @param array $claimedChannels channel code => claiming method + * + * @return array + */ + private function channelChoiceAttributes(ChannelInterface $channel, array $claimedChannels): array + { + $channelCode = (string) $channel->getCode(); + $claimedBy = $claimedChannels[$channelCode] ?? null; + + if (null === $claimedBy) { + return []; + } + + return [ + 'disabled' => true, + 'title' => $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict', + [ + '%channel%' => $channelCode, + '%payment_method%' => (string) $claimedBy->getName(), + ], + ), + ]; + } + + private function addChannelConflictErrors( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + string $factoryName, + ): void { + if (!$form->has('channels')) { + return; + } + + $seenMessages = []; + foreach ($this->conflictChecker->findConflicts($paymentMethod, $factoryName) as $conflict) { + $message = $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict', + [ + '%channel%' => (string) $conflict['channel']->getCode(), + '%payment_method%' => (string) $conflict['paymentMethod']->getName(), + ], + ); + + if (\in_array($message, $seenMessages, true)) { + continue; + } + + $seenMessages[] = $message; + $form->get('channels')->addError(new FormError($message)); + } + } + + private function addBaseCurrencyErrors( + FormInterface $form, + PaymentMethodInterface $paymentMethod, + GatewayConfigInterface $gatewayConfig, + AbstractGatewayConfigurationType $configurationType, + ): void { + if ( + !$form->has('channels') || + !$configurationType->shouldValidateBaseCurrency($gatewayConfig->getConfig()) + ) { + return; + } + + $seenMessages = []; + foreach ($paymentMethod->getChannels() as $channel) { + if (!$channel instanceof ChannelInterface) { + continue; + } + + $baseCurrency = $channel->getBaseCurrency(); + + if (null === $baseCurrency || $configurationType->getBaseCurrencyCode() === $baseCurrency->getCode()) { + continue; + } + + $message = $configurationType->baseCurrencyViolationMessage($channel); + + if (\in_array($message, $seenMessages, true)) { + continue; + } + + $seenMessages[] = $message; + $form->get('channels')->addError(new FormError($message)); + } + } +} diff --git a/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php b/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php new file mode 100644 index 00000000..4cecf182 --- /dev/null +++ b/src/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtension.php @@ -0,0 +1,43 @@ +add(ScalapayGatewayFactory::MIN_AMOUNT, MoneyType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.min_amount', + 'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help', + 'currency' => 'EUR', + 'required' => false, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + ]) + ->add(ScalapayGatewayFactory::MAX_AMOUNT, MoneyType::class, [ + 'label' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.max_amount', + 'help' => 'payplug_sylius_payplug_plugin.ui.scalapay_gateway_config.amount_help', + 'currency' => 'EUR', + 'required' => false, + 'validation_groups' => AbstractGatewayConfigurationType::VALIDATION_GROUPS, + ]) + ; + } + + public static function getExtendedTypes(): iterable + { + return [ScalapayGatewayConfigurationType::class]; + } +} diff --git a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php index 80fce24a..867dbcab 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -4,19 +4,11 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type; -use Doctrine\Common\Collections\Collection; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; -use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface; use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Resource\Repository\RepositoryInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormError; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\Translation\TranslatorInterface; class AbstractGatewayConfigurationType extends AbstractType @@ -25,14 +17,10 @@ class AbstractGatewayConfigurationType extends AbstractType protected string $gatewayFactoryTitle = ''; - protected string $gatewayFactoryName = ''; - protected string $gatewayBaseCurrencyCode = PayPlugGatewayFactory::BASE_CURRENCY_CODE; public function __construct( protected TranslatorInterface $translator, - private RepositoryInterface $gatewayConfigRepository, - protected RequestStack $requestStack, ) { } @@ -56,67 +44,46 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'mapped' => false, 'required' => false, ]) - ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { - $this->checkCreationRequirements( - $this->gatewayFactoryTitle, - $this->gatewayFactoryName, - $event->getForm(), - ); - - /** @phpstan-ignore-next-line */ - $formChannels = $event->getForm()->getParent()->getParent()->get('channels'); - $dataFormChannels = $formChannels->getData(); - if (!$dataFormChannels instanceof Collection) { - return; - } - /** @var ChannelInterface $dataFormChannel */ - foreach ($dataFormChannels as $key => $dataFormChannel) { - $baseCurrency = $dataFormChannel->getBaseCurrency(); - if (null === $baseCurrency) { - continue; - } - $baseCurrencyCode = $baseCurrency->getCode(); - if ($this->gatewayBaseCurrencyCode !== $baseCurrencyCode) { - $message = $this->translator->trans( - 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', - [ - '#channel_code#' => $dataFormChannel->getCode(), - '#payment_method#' => $this->gatewayFactoryTitle, - ], - ); - $formChannels->get((string) $key)->addError(new FormError($message)); - $this->requestStack->getSession()->getFlashBag()->add('error', $message); - } - } - }) ; } - private function canBeCreated(string $factoryName): bool + /** + * Hook for subtypes to scope the base-currency-per-channel restriction enforced by + * PaymentMethodTypeExtension. + * Default: always enforced, preserving today's behavior for every gateway that doesn't + * override this (Bancontact, American Express, Scalapay, Wero, Oney...). + * + * @see baseCurrencyViolationMessage() Companion hook customizing the message this guards. + * + * @param array $gatewayConfig Mapped gateway configuration, as stored on GatewayConfig. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function shouldValidateBaseCurrency(array $gatewayConfig): bool { - $alreadyExists = $this->gatewayConfigRepository->findOneBy(['factoryName' => $factoryName]); - - return !$alreadyExists instanceof GatewayConfigInterface; + return true; } - private function checkCreationRequirements( - string $factoryTitle, - string $factoryName, - FormInterface $form, - ): void { - /** @phpstan-ignore-next-line */ - $paymentMethod = $form->getParent()->getParent()->getData(); - - if (null !== $paymentMethod->getId()) { - return; - } - - if ($this->canBeCreated($factoryName)) { - return; - } + /** + * Hook for subtypes to customize the currency-violation message. Default matches today's + * generic wording, used by every gateway subtype that doesn't override it (Bancontact, + * American Express, Scalapay, Wero, Oney...). + * + * @see shouldValidateBaseCurrency() Companion hook scoping when this message is used. + */ + public function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + return $this->translator->trans( + 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', + [ + '#channel_code#' => $channel->getCode(), + '#payment_method#' => $this->gatewayFactoryTitle, + ], + ); + } - $message = $this->translator->trans('payplug_sylius_payplug_plugin.form.only_one_gateway_allowed', ['%gateway_title%' => $factoryTitle]); - /* @phpstan-ignore-next-line */ - $form->getParent()->getParent()->get('enabled')->addError(new FormError($message)); + public function getBaseCurrencyCode(): string + { + return $this->gatewayBaseCurrencyCode; } } diff --git a/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php b/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php index 4387942b..7231686e 100644 --- a/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AmericanExpressGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class AmericanExpressGatewayConfigurationType extends AbstractGatewayConfi { protected string $gatewayFactoryTitle = AmericanExpressGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = AmericanExpressGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = AmericanExpressGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php b/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php index 1f3da3ec..2c831a0d 100644 --- a/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/ApplePayGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class ApplePayGatewayConfigurationType extends AbstractGatewayConfiguratio { protected string $gatewayFactoryTitle = ApplePayGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = ApplePayGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = ApplePayGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php b/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php index 25eac35b..c7bf6803 100644 --- a/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/BancontactGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class BancontactGatewayConfigurationType extends AbstractGatewayConfigurat { protected string $gatewayFactoryTitle = BancontactGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = BancontactGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = BancontactGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/OneyGatewayConfigurationType.php b/src/Gateway/Form/Type/OneyGatewayConfigurationType.php index fd548251..b446b709 100644 --- a/src/Gateway/Form/Type/OneyGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/OneyGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class OneyGatewayConfigurationType extends AbstractGatewayConfigurationTyp { protected string $gatewayFactoryTitle = OneyGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = OneyGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = OneyGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php index f9056fdf..309e5478 100644 --- a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php @@ -5,6 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Form\Type; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use Sylius\Component\Core\Model\ChannelInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; #[AutoconfigureTag( @@ -19,7 +20,32 @@ final class PayPlugGatewayConfigurationType extends AbstractGatewayConfiguration { protected string $gatewayFactoryTitle = PayPlugGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = PayPlugGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = PayPlugGatewayFactory::BASE_CURRENCY_CODE; + + /** + * Only `integrated_payment` requires every associated channel to be EUR; the redirected + * and `hosted_fields` display modes both work in any currency. + * + * The mode is read back through `resolveDisplayMode()` rather than off a display-mode key: + * `DISPLAY_MODE_FIELD` is an unmapped admin form field and never reaches the persisted config, + * which instead carries the two `INTEGRATED_PAYMENT`/`HOSTED_FIELDS` booleans written by + * `resolveDisplayModeFlags()`. Going through the canonical reader also inherits its + * hosted-fields-wins tie-break when both flags are somehow true. + * + * @param array $gatewayConfig Mapped gateway configuration, as stored on GatewayConfig. + */ + public function shouldValidateBaseCurrency(array $gatewayConfig): bool + { + return PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT === PayPlugGatewayFactory::resolveDisplayMode($gatewayConfig); + } + + /** + * shouldValidateBaseCurrency() above only ever lets this fire for `integrated_payment` mode + * (redirected/hosted_fields both return false there), so this message can be specific to + * that mode rather than the generic per-gateway wording. + */ + public function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + return $this->translator->trans('payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible'); + } } diff --git a/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php index ec89fb96..d240aa04 100644 --- a/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/ScalapayGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class ScalapayGatewayConfigurationType extends AbstractGatewayConfiguratio { protected string $gatewayFactoryTitle = ScalapayGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = ScalapayGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = ScalapayGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/Form/Type/WeroGatewayConfigurationType.php b/src/Gateway/Form/Type/WeroGatewayConfigurationType.php index 39d1a035..79055931 100644 --- a/src/Gateway/Form/Type/WeroGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/WeroGatewayConfigurationType.php @@ -19,7 +19,5 @@ final class WeroGatewayConfigurationType extends AbstractGatewayConfigurationTyp { protected string $gatewayFactoryTitle = WeroGatewayFactory::FACTORY_TITLE; - protected string $gatewayFactoryName = WeroGatewayFactory::FACTORY_NAME; - protected string $gatewayBaseCurrencyCode = WeroGatewayFactory::BASE_CURRENCY_CODE; } diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 99cb0e41..8e0b37eb 100644 --- a/src/Gateway/PayPlugGatewayFactory.php +++ b/src/Gateway/PayPlugGatewayFactory.php @@ -4,6 +4,8 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway; +use Sylius\Component\Payment\Model\GatewayConfigInterface; + final class PayPlugGatewayFactory extends AbstractGatewayFactory { public const FACTORY_NAME = 'payplug'; @@ -13,7 +15,112 @@ final class PayPlugGatewayFactory extends AbstractGatewayFactory // Custom gateway configuration keys public const ONE_CLICK = 'oneClick'; + // Session-protocol sentinel meaning "the customer chose to pay with a different/new card + // rather than one of their saved aliases" — shared by PayPlugPaymentDataCreator (legacy SDK + // flow) and CaptureAliasPaymentRequestHandler (Hosted Fields flow). + public const CARD_CHOICE_OTHER = 'other'; + public const INTEGRATED_PAYMENT = 'integratedPayment'; public const DEFERRED_CAPTURE = 'deferredCapture'; + + public const HOSTED_FIELDS = 'hostedFields'; + + public const HF_IDENTIFIER = 'hfIdentifier'; + + // Unmapped admin form field driving INTEGRATED_PAYMENT/HOSTED_FIELDS below + public const DISPLAY_MODE_FIELD = 'hostedFieldsMode'; + + public const DISPLAY_MODE_INTEGRATED_PAYMENT = 'integrated_payment'; + + public const DISPLAY_MODE_HOSTED_FIELDS = 'hosted_fields'; + + /** + * Derives the admin form radio's initial selection from persisted config. + * Hosted Fields wins if both flags are somehow true, since only it carries the + * mandatory Account ID field the merchant would otherwise lose sight of. + */ + public static function resolveDisplayMode(array $config): ?string + { + if (true === ($config[self::HOSTED_FIELDS] ?? false)) { + return self::DISPLAY_MODE_HOSTED_FIELDS; + } + if (true === ($config[self::INTEGRATED_PAYMENT] ?? false)) { + return self::DISPLAY_MODE_INTEGRATED_PAYMENT; + } + + return null; + } + + /** + * Derives the two persisted booleans from the submitted radio value. + * Always returns both keys explicitly (rather than only the "true" one) so that switching + * away from a previously-selected mode clears the stale flag instead of leaving it behind. + * + * @return array{integratedPayment: bool, hostedFields: bool} + */ + public static function resolveDisplayModeFlags(?string $displayMode): array + { + return [ + self::INTEGRATED_PAYMENT => self::DISPLAY_MODE_INTEGRATED_PAYMENT === $displayMode, + self::HOSTED_FIELDS => self::DISPLAY_MODE_HOSTED_FIELDS === $displayMode, + ]; + } + + /** + * @param array $rawFormData Display-mode/HF-identifier values as submitted + * (assembled from the config form's already-submitted + * child forms at POST_SUBMIT, not PRE_SUBMIT's raw + * payload). + * + * @return list Config keys (HF_IDENTIFIER) that are blank while hosted_fields is + * selected; empty if hosted_fields isn't selected or the field is filled. + */ + public static function missingHostedFieldsRequirements(array $rawFormData): array + { + if (self::DISPLAY_MODE_HOSTED_FIELDS !== ($rawFormData[self::DISPLAY_MODE_FIELD] ?? null)) { + return []; + } + + $missing = []; + if (self::isBlank($rawFormData[self::HF_IDENTIFIER] ?? '')) { + $missing[] = self::HF_IDENTIFIER; + } + + return $missing; + } + + /** + * True only for a `payplug`-factory gateway config with Hosted Fields selected. Used to + * decide, within the single `payplug`-tagged command providers shared by every gateway + * (Capture/Notify/StatusPaymentRequestCommandProvider), whether to delegate to the + * Hosted-Fields-specific variant instead of the legacy PayPlug SDK flow — other gateways + * (Oney, Bancontact, ...) never satisfy this check, so their behavior is unaffected. + */ + public static function isHostedFieldsConfig(?GatewayConfigInterface $gatewayConfig): bool + { + return self::FACTORY_NAME === $gatewayConfig?->getFactoryName() && + true === ($gatewayConfig->getConfig()[self::HOSTED_FIELDS] ?? false); + } + + /** + * True for a Hosted Fields config whose merchant opted into deferred capture: its payments are + * then created as authorizations only (UPC capture=false), captured or cancelled later from the + * admin order screen. The legacy SDK flow reads the same DEFERRED_CAPTURE flag on its own + * (PayPlugPaymentDataCreator), so this deliberately excludes it. + */ + public static function isDeferredCaptureHostedFieldsConfig(?GatewayConfigInterface $gatewayConfig): bool + { + return self::isHostedFieldsConfig($gatewayConfig) && + true === ($gatewayConfig?->getConfig()[self::DEFERRED_CAPTURE] ?? false); + } + + private static function isBlank(mixed $value): bool + { + if (!is_scalar($value)) { + return true; + } + + return '' === trim((string) $value); + } } diff --git a/src/Gateway/ScalapayGatewayFactory.php b/src/Gateway/ScalapayGatewayFactory.php index 5e4b063a..e982ffaa 100644 --- a/src/Gateway/ScalapayGatewayFactory.php +++ b/src/Gateway/ScalapayGatewayFactory.php @@ -11,4 +11,8 @@ final class ScalapayGatewayFactory extends AbstractGatewayFactory public const FACTORY_TITLE = 'Scalapay by PayPlug'; public const PAYMENT_METHOD_SCALAPAY = 'scalapay'; + + public const MIN_AMOUNT = 'min_amount'; + + public const MAX_AMOUNT = 'max_amount'; } diff --git a/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php new file mode 100644 index 00000000..2577dcf8 --- /dev/null +++ b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflict.php @@ -0,0 +1,42 @@ +enable()`). + * + * A conflicting config created through the admin API is therefore caught on its next ordinary + * update rather than at creation. This is a pre-existing gap — the `canBeCreated()` check that + * preceded PRE-3628 had the identical hole — and closing it properly means a class-level + * constraint on `PaymentMethodInterface` in the resource's validation config, not another hook + * here. Until then the invariant is a strong default, not a guarantee: read it as such wherever + * code assumes at most one enabled gateway per factory per channel. + * + * @Annotation + */ +final class HasNoGatewayChannelConflict extends Constraint +{ + /** @var string */ + public $message = 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict'; + + public function validatedBy(): string + { + return HasNoGatewayChannelConflictValidator::class; + } +} diff --git a/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidator.php b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidator.php new file mode 100644 index 00000000..5649fbbd --- /dev/null +++ b/src/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidator.php @@ -0,0 +1,64 @@ +resolveFactoryName($value); + + if (null === $factoryName) { + return; + } + + foreach ($this->conflictChecker->findConflicts($value, $factoryName) as $conflict) { + $this->context->buildViolation($constraint->message) + ->setParameter('%channel%', (string) $conflict['channel']->getCode()) + ->setParameter('%payment_method%', (string) $conflict['paymentMethod']->getName()) + ->atPath('channels') + ->addViolation(); + } + } + + /** + * Null for anything this constraint has no opinion on — a method with no gateway config, or a + * disabled one. `findConflicts()` bails on a disabled subject anyway; short-circuiting here + * saves the query. + */ + private function resolveFactoryName(PaymentMethodInterface $paymentMethod): ?string + { + if (!$paymentMethod->isEnabled()) { + return null; + } + + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + if (!$gatewayConfig instanceof GatewayConfigInterface) { + return null; + } + + return $gatewayConfig->getFactoryName(); + } +} diff --git a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php index 7cc33f5b..da67d8f7 100644 --- a/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php +++ b/src/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidator.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints; use Payplug\Exception\UnauthorizedException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Checker\CanSavePayplugPaymentMethodChecker; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; @@ -21,9 +21,12 @@ */ final class IsCanSavePaymentMethodValidator extends ConstraintValidator { + // `payplug` processes card payments directly and `payplug_oney` has its own dedicated + // constraint (IsOneyEnabled); neither is an alternative payment method requiring its own + // per-account enablement flag from PayPlug. private const GATEWAYS_SKIP = [PayPlugGatewayFactory::FACTORY_NAME, OneyGatewayFactory::FACTORY_NAME]; - public function __construct(private PayPlugApiClientFactory $apiClientFactory) + public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory) { } diff --git a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php index 0d889031..7237b12e 100644 --- a/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php +++ b/src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints; use Payplug\Exception\UnauthorizedException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker; use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; @@ -18,7 +18,7 @@ final class IsOneyEnabledValidator extends ConstraintValidator { - public function __construct(private PayPlugApiClientFactory $apiClientFactory) + public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory) { } diff --git a/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php b/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php new file mode 100644 index 00000000..10c0d5ee --- /dev/null +++ b/src/Gateway/Validator/Constraints/IsScalapayAmountRangeValid.php @@ -0,0 +1,22 @@ +resolveApplicableConfiguredAmounts($value, $constraint); + if (null === $configuredAmounts) { + return; + } + + $authorizedRange = $this->resolveAuthorizedRange($value); + if (null === $authorizedRange) { + return; + } + + $this->applyRangeViolations($configuredAmounts, $authorizedRange, $constraint); + } + + /** + * Resolves the merchant-configured amounts, applying the early guards that don't need a + * live API call: the method must be enabled, amounts must be configured, and — when both + * sides are explicitly set — locally consistent. + * + * @return array{0: int|null, 1: int|null}|null + */ + private function resolveApplicableConfiguredAmounts( + PaymentMethodInterface $paymentMethod, + IsScalapayAmountRangeValid $constraint, + ): ?array { + $configuredAmounts = false !== $paymentMethod->isEnabled() ? $this->resolveConfiguredAmounts($paymentMethod) : null; + if (null === $configuredAmounts) { + return null; + } + + [$minAmount, $maxAmount] = $configuredAmounts; + + if (\is_int($minAmount) && \is_int($maxAmount) && $minAmount > $maxAmount) { + $this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation(); + + return null; + } + + return $configuredAmounts; + } + + /** + * @param array{0: int|null, 1: int|null} $configuredAmounts + * @param array{min_amount: int, max_amount: int} $authorizedRange + */ + private function applyRangeViolations( + array $configuredAmounts, + array $authorizedRange, + IsScalapayAmountRangeValid $constraint, + ): void { + [$minAmount, $maxAmount] = $configuredAmounts; + + // A merchant may configure only one side of the range; the other falls back to the + // API bound at checkout (see SupportedMethodsProvider), so the min>max check must + // compare against that same effective range, not just the explicitly configured side. + $effectiveMinAmount = $minAmount ?? $authorizedRange['min_amount']; + $effectiveMaxAmount = $maxAmount ?? $authorizedRange['max_amount']; + + if ($effectiveMinAmount > $effectiveMaxAmount) { + $this->context->buildViolation($constraint->minGreaterThanMaxMessage)->addViolation(); + + return; + } + + if ( + (\is_int($minAmount) && $minAmount < $authorizedRange['min_amount']) || + (\is_int($maxAmount) && $maxAmount > $authorizedRange['max_amount']) + ) { + $this->context->buildViolation($constraint->outOfRangeMessage) + ->setParameter('%min_amount%', self::formatAmount($authorizedRange['min_amount'])) + ->setParameter('%max_amount%', self::formatAmount($authorizedRange['max_amount'])) + ->addViolation() + ; + } + } + + /** + * The bounds are EUR cents (the form field is hardcoded to EUR), rendered with two decimals so + * 500 reads as "5.00" rather than "5". + */ + private static function formatAmount(int $amountInCents): string + { + return number_format($amountInCents / 100, 2, '.', ''); + } + + /** + * @return array{0: int|null, 1: int|null}|null + */ + private function resolveConfiguredAmounts(PaymentMethodInterface $paymentMethod): ?array + { + $gatewayConfig = $paymentMethod->getGatewayConfig(); + + if (!$gatewayConfig instanceof GatewayConfigInterface || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { + return null; + } + + [$minAmount, $maxAmount] = $this->readConfiguredAmounts($gatewayConfig->getConfig()); + + return null === $minAmount && null === $maxAmount ? null : [$minAmount, $maxAmount]; + } + + /** + * The admin form only ever writes null or an int, but the gateway config is a plain serialized + * array that a direct DB edit, an import script or an admin API write can leave anything in. + * PaymentMethodValidator::process() has no surrounding try/catch, so a malformed value + * degrades to "not configured" — leaving the API bounds in force at checkout — rather than + * throwing an assertion error that would 500 the admin save. + * + * @param array $config + * + * @return array{0: int|null, 1: int|null} + */ + private function readConfiguredAmounts(array $config): array + { + $minAmount = $config[ScalapayGatewayFactory::MIN_AMOUNT] ?? null; + $maxAmount = $config[ScalapayGatewayFactory::MAX_AMOUNT] ?? null; + + try { + Assert::nullOrInteger($minAmount); + Assert::nullOrInteger($maxAmount); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('Skipping Scalapay amount range validation: the stored range is malformed.', [ + 'min_amount' => $minAmount, + 'max_amount' => $maxAmount, + 'exception' => $exception->getMessage(), + ]); + + return [null, null]; + } + + return [$minAmount, $maxAmount]; + } + + /** + * Fails open: when the authorized range can't be established the config saves unvalidated, + * matching the plugin's convention of never blocking an admin save on an API hiccup. That is + * not free — a one-sided range that inverts against the live API bounds slips through and + * silently hides Scalapay at checkout — so the skip is logged rather than swallowed. + * + * @return array{min_amount: int, max_amount: int}|null + */ + private function resolveAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array + { + try { + $authorizedRange = $this->resolveApiAuthorizedRange($paymentMethod); + } catch (GatewayConfigurationException | PayplugException | InvalidArgumentException $exception) { + $this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account could not be read.', [ + 'payment_method' => $paymentMethod->getCode(), + 'exception' => $exception->getMessage(), + ]); + + return null; + } + + if (null === $authorizedRange) { + $this->logger->warning('Skipping Scalapay amount range validation: the PayPlug account authorizes no EUR range for Scalapay.', [ + 'payment_method' => $paymentMethod->getCode(), + ]); + } + + return $authorizedRange; + } + + /** + * @return array{min_amount: int, max_amount: int}|null + */ + private function resolveApiAuthorizedRange(PaymentMethodInterface $paymentMethod): ?array + { + $account = $this->apiClientFactory->createForPaymentMethod($paymentMethod)->getAccount(); + $currencies = $this->amountRangeResolver->resolve($account, ScalapayGatewayFactory::PAYMENT_METHOD_SCALAPAY); + + return $currencies['EUR'] ?? null; + } +} diff --git a/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php b/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php index 328b47e8..eaf8f752 100644 --- a/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php +++ b/src/Gateway/Validator/Constraints/PayplugPermissionValidator.php @@ -5,7 +5,7 @@ namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints; use Payplug\Exception\UnauthorizedException; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -13,7 +13,7 @@ final class PayplugPermissionValidator extends ConstraintValidator { - public function __construct(private PayPlugApiClientFactory $apiClientFactory) + public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory) { } diff --git a/src/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php new file mode 100644 index 00000000..df02fd72 --- /dev/null +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -0,0 +1,499 @@ + $headers + * + * @throws InvalidNotificationException if the notification fails signature verification or + * parsing — the caller is expected to catch this, same + * as it already does for the legacy SDK's PayplugException. + */ + public function treat(PaymentInterface $payment, string $rawBody, array $headers): void + { + // The shared secret the notification is verified against is stored per gateway config, so + // it has to be read from the account this payment was created on — another channel's + // header would reject a legitimate webhook (or, worse, accept a foreign one). + $method = $payment->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('The payment has no payment method, so no webhook secret can be resolved for it.'); + } + + $expectedHeader = $this->configurationRepository->forPaymentMethod($method)->get(self::CONFIG_KEY_WEBHOOK_AUTHORIZATION_HEADER) ?? ''; + $operationData = WebhookNotificationHelper::parse($headers, $rawBody, $expectedHeader); + + if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) { + // Not a final outcome — leave the payment as-is and, crucially, do not touch + // isTreated()/markTreated(): a later, final notification for this same operation + // must still be free to apply once it arrives. The 0001-is-pending knowledge itself + // now lives in payplug/unified-plugin-core's ExecCodeMapper (see its docblock for the + // full execcode-catalog reasoning), not duplicated here. Must run before any refund + // matching below: a 3DS-pending notification is never a refund confirmation, and + // classifying it as one this early would be wrong regardless of whether its + // operationId also happens to match a recorded refund. + return; + } + + // A notification whose operation id matches one RefundPaymentProcessor already recorded + // under $details['refunds'] (for both full and partial UHF refunds) confirms a refund + // operation, not the payment's own outcome — ExecCodeMapper's "0000" => PAID mapping is + // payment-shaped and would otherwise misreport a successful refund as the payment being + // paid. UPC has no "refund" concept in its execCode/outcome vocabulary to lean on here + // (see ExecCodeMapper), so this classification is made locally, from ids this plugin + // itself generated and already knows the meaning of. + $refundAmount = self::findMatchingRefundAmount($payment, $operationData->operationId); + + // Same local classification for a deferred-capture payment's captures/cancellations: + // AuthorizedPaymentOperationProcessor recorded each one's operation id when it triggered + // it, and already applied its effect on the payment state synchronously — the webhook + // only confirms it. + $authorizationOperation = null === $refundAmount + ? AuthorizationDetails::fromDetails($payment->getDetails())->findOperation($operationData->operationId) + : null; + if (null !== $authorizationOperation) { + $this->treatAuthorizationOperation($payment, $rawBody, $operationData, $authorizationOperation); + + return; + } + + if (null === $refundAmount) { + $this->resolveAuthorizationOutcome($payment, $rawBody, $operationData); + } + + $this->applyConfirmation($payment, $rawBody, $operationData, $refundAmount); + } + + // Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142). + // $refundAmount is non-null when the notification confirms one of the payment's recorded refunds. + private function applyConfirmation( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + ?int $refundAmount, + ): void { + if (!$this->matchesPayment($payment, $operationData, $refundAmount ?? $payment->getAmount())) { + return; + } + + if (null === $refundAmount) { + $this->applyLocked($payment, $rawBody, $operationData); + + return; + } + + if (PaymentOutcome::PAID === $operationData->outcome) { + $operationData->outcome = PaymentOutcome::REFUNDED; + $this->applyLocked($payment, $rawBody, $operationData); + + return; + } + + $this->treatFailedRefund($payment, $rawBody, $operationData); + } + + // The refund itself failed (or is still pending) per its own execCode — this must never be + // forced into REFUNDED (the money never moved), nor forwarded as-is to the payment's own state + // machine: PaymentOutcome::FAILED maps to TRANSITION_FAIL (see SyliusOrderStateMutator), which + // means "this PAYMENT failed," not "this refund attempt failed" — the underlying payment + // already succeeded, only the refund didn't. Track/log only, so this notification stops being + // redelivered without ever touching the Payment's own state. + private function treatFailedRefund(PaymentInterface $payment, string $rawBody, OperationData $operationData): void + { + $this->logger->error('[PayPlug][UPC] Refund confirmation reports a non-success outcome.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation_id' => $operationData->operationId, + 'outcome' => $operationData->outcome, + 'exec_code' => $operationData->execCode, + ]); + + if (!$this->markMatchedRefundAsFailedLocked($payment, $operationData->operationId)) { + // Couldn't acquire the lock guarding this payment's $details['refunds'] — + // RefundPaymentProcessor is creating a refund for it right now (see + // markMatchedRefundAsFailedLocked()'s own docblock). Return without calling + // applyLocked(): isTreated()/markTreated() are never touched, so this notification + // stays free to be redelivered and retried once that refund creation has released the + // lock, instead of being marked treated without its 'failed' flag ever being recorded. + return; + } + + $this->applyLocked($payment, $rawBody, $operationData, applyOutcome: false); + } + + /** + * For an authorization-only payment, the confirmation of its own creation reads "authorized", + * never "paid" (see AuthorizationDetails::resolveCreationOutcome()) — and, after a 3DS + * challenge, is also the first place its capture deadline shows up. Any other operation id + * reaching here on such a payment is one Sylius never triggered: mapping it to AUTHORIZED + * too keeps it from ever completing the payment on a guess. + */ + private function resolveAuthorizationOutcome( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + ): void { + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + if (!$authorization->isDeferred()) { + return; + } + + $operationData->outcome = $authorization->resolveCreationOutcome($operationData->outcome); + if (PaymentOutcome::AUTHORIZED === $operationData->outcome) { + $payment->setDetails(AuthorizationWebhookBody::withMaxCaptureDate($payment->getDetails(), $rawBody)); + } + } + + /** + * A success confirms what the payment state already reflects, so it is only tracked as + * treated. A failure means an operation accepted synchronously did not actually go through: + * its entry is flagged failed (so the remaining capturable amount counts it back in) and, since + * the payment may already have moved on to "completed"/"cancelled" on the strength of it, + * logged critically for the merchant to reconcile — no automatic transition can undo those. + * + * @param array{operation: string, amount: int} $authorizationOperation + */ + private function treatAuthorizationOperation( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + array $authorizationOperation, + ): void { + if (!$this->matchesPayment($payment, $operationData, $authorizationOperation['amount'])) { + return; + } + + if (PaymentOutcome::PAID !== $operationData->outcome) { + $this->logger->critical('[PayPlug][UPC] An authorization operation accepted earlier is reported as not completed; the payment needs manual reconciliation.', [ + 'sylius_payment_id' => $payment->getId(), + 'payment_state' => $payment->getState(), + 'operation' => $authorizationOperation['operation'], + 'operation_id' => $operationData->operationId, + 'outcome' => $operationData->outcome, + 'exec_code' => $operationData->execCode, + ]); + + $lockKey = AuthorizationLockKey::forPaymentId($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + // An operation on this payment is in progress; leave this notification untreated + // so its redelivery flags the entry once the lock is free. + return; + } + + try { + $payment->setDetails(AuthorizationDetails::withOperationFailed($payment->getDetails(), $authorizationOperation['operation'], $operationData->operationId)); + } finally { + $this->lock->release($lockKey); + } + } + + $this->applyLocked($payment, $rawBody, $operationData, applyOutcome: false); + } + + // Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) — + // same rationale as matchesPayment() below: this is its own self-contained "acquire, check + // idempotency, apply" unit, not a fragment that needs to share treat()'s return budget. + // $applyOutcome false skips the orderStateMutator call while still tracking the notification + // as treated — used when the resolved $operationData->outcome must not reach the Payment's + // own state machine at all (see treatFailedRefund() above); a refund + // confirmation never reaches maybeSaveCard() either way, since that only ever runs alongside + // a genuine PAID outcome being applied. + private function applyLocked( + PaymentInterface $payment, + string $rawBody, + OperationData $operationData, + bool $applyOutcome = true, + ): void { + $lockKey = self::LOCK_KEY_PREFIX . $operationData->operationId; + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + // Another delivery/poll for the same operation is already being processed — whichever + // holds the lock applies the outcome, nothing more to do here. + return; + } + + try { + if ($this->paymentRepository->isTreated($operationData->operationId)) { + return; + } + + $this->paymentRepository->save($operationData); + if ($applyOutcome) { + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $operationData->outcome); + } + $this->paymentRepository->markTreated($operationData->operationId); + + // AUTHORIZED too: a deferred-capture payment's card is saved once its authorization + // is confirmed, exactly like an immediate one's once it is paid. Never for a + // notification only tracked ($applyOutcome false) — a capture/cancellation + // confirmation also reads PAID, and has no card to save. + if ($applyOutcome && \in_array($operationData->outcome, [PaymentOutcome::PAID, PaymentOutcome::AUTHORIZED], true)) { + $this->maybeSaveCard($payment, $rawBody); + } + } finally { + $this->lock->release($lockKey); + } + } + + // A 3DS-challenge capture never gets an alias back synchronously (CaptureHostedPaymentRequestHandler + // only sees one on a direct, frictionless success) — this webhook, fired once the challenge is + // validated, is the only place a 3DS payment's card ever gets saved. The alias/card metadata + // itself is already in $rawBody, as paymentMethod.{storedId, card, details}, so no extra API call + // is needed here. The operation resource CaptureHostedPaymentRequestHandler fetches separately + // carries the alias as paymentMethod.id; CardDataFromPaymentMethodExtractor reads both. + private function maybeSaveCard(PaymentInterface $payment, string $rawBody): void + { + $details = $payment->getDetails(); + if (true !== ($details['hosted_fields_save_card'] ?? false)) { + return; + } + + $method = $payment->getMethod(); + if (null === $method) { + return; + } + + $cardData = CardDataFromPaymentMethodExtractor::extractFromDecoded(\json_decode($rawBody, true)); + $aliasId = $cardData['aliasId'] ?? null; + if (null === $aliasId) { + $this->logger->error('[PayPlug][UPC] Save-card was requested but the webhook notification carried no alias id.', [ + 'sylius_payment_id' => $payment->getId(), + ]); + + return; + } + + $this->cardPersister->persist($aliasId, $payment, $method, $details, $cardData); + } + + // Split out of treat() to keep its own return count within SonarCloud's limit (php:S1142) — + // both branches here mean "nothing to apply," they just differ in whether that's expected + // (still-pending) or a problem worth logging over (mismatch). + private function matchesPayment(PaymentInterface $payment, OperationData $operationData, ?int $expectedAmount): bool + { + if (PaymentOutcome::THREE_DS_PENDING === $operationData->outcome) { + // Not a final outcome — leave the payment as-is and, crucially, do not touch + // isTreated()/markTreated(): a later, final notification for this same operation + // must still be free to apply once it arrives. The 0001-is-pending knowledge itself + // now lives in payplug/unified-plugin-core's ExecCodeMapper (see its docblock for the + // full execcode-catalog reasoning), not duplicated here. + return false; + } + + $expectedOrderId = PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()); + if ($operationData->orderId !== $expectedOrderId || $operationData->amount !== $expectedAmount) { + $this->logger->error('[PayPlug][UPC] Hosted Fields webhook notification does not match the payment it was resolved against.', [ + 'sylius_payment_id' => $payment->getId(), + 'expected_order_id' => $expectedOrderId, + 'received_order_id' => $operationData->orderId, + 'expected_amount' => $expectedAmount, + 'received_amount' => $operationData->amount, + ]); + + return false; + } + + return true; + } + + // $details['refunds'] entries are RefundPaymentProcessor's own — see + // processHostedFields()/processHostedFieldsWithAmount() — {internal_id, id, amount}, id being + // the refund operation's own id (from createRefund()'s response operationIds[0]). + private static function findMatchingRefundAmount(PaymentInterface $payment, string $operationId): ?int + { + $refunds = self::resolveOwnRefunds($payment, $operationId); + if (null === $refunds) { + return null; + } + + $index = self::findMatchingRefundIndex($refunds, $operationId); + if (null === $index) { + return null; + } + + $entry = $refunds[$index]; + $amount = \is_array($entry) ? ($entry['amount'] ?? null) : null; + + return \is_int($amount) ? $amount : null; + } + + /** + * Acquires RefundDetailsLockKey before calling markMatchedRefundAsFailed() below, so this + * read-modify-write of $details['refunds'] can't interleave with + * RefundPaymentProcessor::processHostedFields()/processHostedFieldsWithAmount()'s own — which + * acquire the very same key around their (network-call-spanning) read-modify-write of that + * same array — and silently lose one of the two writes. Returns false, without calling + * markMatchedRefundAsFailed() at all, when the lock is already held (a refund creation for + * this payment is in progress right now): the caller must not proceed to mark this + * notification treated in that case, so it stays free to be redelivered and retried once the + * lock is free. + */ + private function markMatchedRefundAsFailedLocked(PaymentInterface $payment, string $operationId): bool + { + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + $this->logger->error('[PayPlug][UPC] Could not acquire the refund-details lock to flag a failed refund; a refund creation is likely in progress for this payment.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation_id' => $operationId, + ]); + + return false; + } + + try { + self::markMatchedRefundAsFailed($payment, $operationId); + } finally { + $this->lock->release($lockKey); + } + + return true; + } + + /** + * Neutralizes the matched refund entry's 'amount' contribution — flags it 'failed' => true — + * so a later RefundPaymentProcessor::processHostedFields() full-refund call (which sums every + * $details['refunds'] entry to derive the remaining balance still owed) doesn't count money + * that was accepted synchronously by createRefund() but never actually moved, per this same + * notification's own non-success outcome. The entry itself (id/amount) is kept, not removed, + * as an audit trail of the failed attempt. Only ever called while holding RefundDetailsLockKey + * — see markMatchedRefundAsFailedLocked() above, its only caller. + */ + private static function markMatchedRefundAsFailed(PaymentInterface $payment, string $operationId): void + { + $refunds = self::resolveOwnRefunds($payment, $operationId); + if (null === $refunds) { + return; + } + + $index = self::findMatchingRefundIndex($refunds, $operationId); + if (null === $index || !\is_array($refunds[$index])) { + return; + } + + $refunds[$index]['failed'] = true; + $details = $payment->getDetails(); + $details['refunds'] = $refunds; + $payment->setDetails($details); + } + + /** + * @return mixed[]|null $details['refunds'] as an array, or null when $operationId is either + * the known payment-creation operation id (never a refund — see the inline comment + * below) or $details['refunds'] itself isn't a usable array. + */ + private static function resolveOwnRefunds(PaymentInterface $payment, string $operationId): ?array + { + $details = $payment->getDetails(); + + // The original payment-creation notification always carries the exact operation id + // CaptureHostedPaymentRequestHandler recorded under hosted_fields_operation_id at + // creation time — never a refund. The unresolved-entry fallback in + // findMatchingRefundIndex() must not misclassify a delayed/redelivered copy of THAT + // notification as an unrelated refund just because a refund with no captured id also + // happens to exist on this payment. + $paymentOperationId = $details['hosted_fields_operation_id'] ?? null; + if (\is_string($paymentOperationId) && $paymentOperationId === $operationId) { + return null; + } + + $refunds = $details['refunds'] ?? null; + + return \is_array($refunds) ? $refunds : null; + } + + /** + * @param mixed[] $refunds + * + * A refund entry with a null id means RefundPaymentProcessor's own createRefund() call + * returned a 2xx response whose body carried no operationIds (logged there as an error at + * the time) — this confirmation is the only remaining way to learn which refund it belongs + * to, so fall back to the most recent such unresolved entry rather than dropping the + * notification entirely (or, worse, letting it fall through unmatched and get misapplied as + * a plain payment confirmation). Ambiguous only if more than one refund for the same payment + * independently hit that same malformed-response edge case, which the upstream error log + * already flags as needing manual attention. + */ + private static function findMatchingRefundIndex(array $refunds, string $operationId): ?int + { + $unresolvedIndex = null; + + foreach ($refunds as $index => $refund) { + if (!\is_int($index) || !\is_array($refund) || !\is_int($refund['amount'] ?? null)) { + continue; + } + + $refundOperationId = $refund['id'] ?? null; + if ($refundOperationId === $operationId) { + return $index; + } + + if (null === $refundOperationId) { + $unresolvedIndex = $index; + } + } + + return $unresolvedIndex; + } +} diff --git a/src/Handler/PaymentNotificationHandler.php b/src/Handler/PaymentNotificationHandler.php index 7e1c8851..c6671c1b 100644 --- a/src/Handler/PaymentNotificationHandler.php +++ b/src/Handler/PaymentNotificationHandler.php @@ -5,7 +5,9 @@ namespace PayPlug\SyliusPayPlugPlugin\Handler; use DateTimeImmutable; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Payplug\Resource\IVerifiableAPIResource; use Payplug\Resource\Payment; use Payplug\Resource\PaymentAuthorization; @@ -31,6 +33,7 @@ public function __construct( private EntityManagerInterface $entityManager, private LockFactory $lockFactory, private RequestStack $requestStack, + private ManagerRegistry $managerRegistry, ) { } @@ -137,6 +140,9 @@ private function saveCard(PaymentInterface $payment, IVerifiableAPIResource $pay return; } + // This check-then-act is not by itself race-proof — two payments notified concurrently + // for the same card alias can both pass this guard — so a DB-level unique constraint on + // (external_id, is_live) backs it up; see the catch below. $card = $this->payplugCardRepository->findOneBy([ 'externalId' => $paymentResource->__get('card')->id, 'isLive' => $paymentResource->is_live, @@ -163,7 +169,16 @@ private function saveCard(PaymentInterface $payment, IVerifiableAPIResource $pay ->setIsLive($paymentResource->is_live) ; - $this->payplugCardRepository->add($card); + try { + $this->payplugCardRepository->add($card); + } catch (UniqueConstraintViolationException) { + // The findOneBy() guard above lost a race against a concurrent save for the same + // alias — that other call already stored the canonical Card row, so there is nothing + // left to do here. Doctrine's UnitOfWork closes the EntityManager on ANY flush + // failure, catch included — reset the registry so Doctrine work resolved fresh after + // this point doesn't inherit the now-closed instance. + $this->managerRegistry->resetManager(); + } } private function isResourceIsAuthorized(IVerifiableAPIResource $paymentResource): bool diff --git a/src/OrderPay/Provider/CaptureHttpResponseProvider.php b/src/OrderPay/Provider/CaptureHttpResponseProvider.php index c5e1b972..5d8b6806 100644 --- a/src/OrderPay/Provider/CaptureHttpResponseProvider.php +++ b/src/OrderPay/Provider/CaptureHttpResponseProvider.php @@ -43,8 +43,13 @@ class CaptureHttpResponseProvider implements HttpResponseProviderInterface { public function supports(RequestConfiguration $requestConfiguration, PaymentRequestInterface $paymentRequest): bool { - return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE && - ($paymentRequest->getResponseData()['redirect_url'] ?? null) !== null; + if ($paymentRequest->getAction() !== PaymentRequestInterface::ACTION_CAPTURE) { + return false; + } + + $data = $paymentRequest->getResponseData(); + + return null !== ($data['redirect_url'] ?? null) || null !== ($data['redirect_html'] ?? null); } public function getResponse( @@ -53,6 +58,14 @@ public function getResponse( ): Response { // This is called after the capture payment request has been handled $data = $paymentRequest->getResponseData(); + + // The Unified API's "recommended for web" 3DS-pending shape (see PaymentOutput, returned by + // both CaptureHostedPaymentRequestHandler and CaptureAliasPaymentRequestHandler): a + // self-submitting HTML form to render as-is, rather than a plain redirect target. + if (\is_string($data['redirect_html'] ?? null)) { + return new Response($data['redirect_html']); + } + if (!\is_string($data['redirect_url'] ?? null)) { throw new \LogicException('Redirect URL is not set in the payment request response data.'); } diff --git a/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php new file mode 100644 index 00000000..80b119b2 --- /dev/null +++ b/src/PaymentProcessing/AuthorizedPaymentOperationProcessor.php @@ -0,0 +1,468 @@ +getMethod(); + + return $method instanceof PaymentMethodInterface && + PayPlugGatewayFactory::isHostedFieldsConfig($method->getGatewayConfig()) && + AuthorizationDetails::fromDetails($payment->getDetails())->isDeferred(); + } + + /** + * Whether an amount in $currencyCode can be converted between the decimal notation the + * merchant types and Sylius's two-decimal minor unit: only when UPC's AmountHelper converts it + * with that same factor of 100. An empty, malformed or 3-decimal code is rejected by the + * helper; a zero-decimal one (JPY, XPF...) converts with a factor of 1. + */ + public static function supportsTypedAmounts(?string $currencyCode): bool + { + if (null === $currencyCode) { + return false; + } + + try { + return 100 === AmountHelper::toCents(1.0, $currencyCode); + } catch (InvalidCurrencyException) { + return false; + } + } + + public function canCapture(PaymentInterface $payment): bool + { + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + + return $this->isAuthorized($payment) && + !$authorization->isExpired($this->clock->now()) && + $authorization->remainingAmount() > 0; + } + + public function canCancel(PaymentInterface $payment): bool + { + return $this->canCapture($payment) && !AuthorizationDetails::fromDetails($payment->getDetails())->hasCaptures(); + } + + /** + * @param int|null $amount null captures everything still capturable + * @param int|null $expectedVersion the AuthorizationDetails::version() the caller's form was + * built against; null skips the replay check (programmatic use) + * + * @throws AuthorizationOperationException + */ + public function capture(PaymentInterface $payment, ?int $amount, ?int $expectedVersion = null): void + { + $this->runLocked($payment, function () use ($payment, $amount, $expectedVersion): void { + $this->assertOperable($payment, $expectedVersion, AuthorizationDetails::OPERATION_CAPTURE); + $this->captureRecorded($payment, $amount); + + if (0 === AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()) { + $this->applyTransition($payment, PaymentTransitions::TRANSITION_COMPLETE); + $this->entityManager->flush(); + } + }); + } + + /** + * @param int|null $amount null cancels everything still authorized + * + * @throws AuthorizationOperationException + */ + public function cancel(PaymentInterface $payment, ?int $amount, ?int $expectedVersion = null): void + { + $this->runLocked($payment, function () use ($payment, $amount, $expectedVersion): void { + $this->assertOperable($payment, $expectedVersion, AuthorizationDetails::OPERATION_CANCELLATION); + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + $remaining = $authorization->remainingAmount(); + $amount = $this->resolveAmount($payment, $amount, $remaining); + + try { + $output = $this->authorizationOperator->cancel( + $this->resolveMethod($payment), + $this->resolveUnifiedApiPaymentId($payment), + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + // Without an amount only on an untouched authorization: an explicit amount is + // what UPC reads as a partial cancellation, which the merchant's contract may + // not allow even when it equals the whole remainder. Once part of it has been + // cancelled, "no amount" would target the original authorization again, so the + // exact remainder is sent instead. + $amount === $remaining && 0 === $authorization->version() ? null : $amount, + $payment->getCurrencyCode(), + $authorization->version() + 1, + ); + } catch (PayplugException $exception) { + throw $this->refusal($payment, AuthorizationDetails::OPERATION_CANCELLATION, $exception); + } + + $this->record($payment, AuthorizationDetails::OPERATION_CANCELLATION, $output->status, $output->body, $amount); + $this->entityManager->flush(); + + if ($amount === $remaining) { + $this->applyTransition($payment, PaymentTransitions::TRANSITION_CANCEL); + $this->entityManager->flush(); + } + }); + } + + /** + * Keeps Sylius's own "complete" transition meaningful for a deferred Hosted Fields payment: + * applied while money is still only authorized — by the native admin "Complete" button, by + * payplug:capture-authorized-payments, or by a merchant's own shipping listener (see + * doc/authorized_payment.md) — it first captures whatever is still capturable. Throwing here + * aborts the transition, so the payment never reads "completed" without the capture behind it. + * + * A no-op when this processor applies "complete" itself after the last capture, since nothing + * is left capturable by then. + */ + #[AsTransitionListener(workflow: PaymentTransitions::GRAPH, transition: PaymentTransitions::TRANSITION_COMPLETE)] + public function onCompleteTransition(TransitionEvent $event): void + { + $payment = $event->getSubject(); + if (!$payment instanceof PaymentInterface || !self::supports($payment) || !$this->isAuthorized($payment)) { + return; + } + + if (0 === AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()) { + return; + } + + $this->runLocked($payment, function () use ($payment): void { + $this->assertOperable($payment, null, AuthorizationDetails::OPERATION_CAPTURE); + $this->captureRecorded($payment, null); + }); + } + + /** + * @throws AuthorizationOperationException + */ + private function captureRecorded(PaymentInterface $payment, ?int $amount): void + { + $remaining = AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount(); + $amount = $this->resolveAmount($payment, $amount, $remaining); + + try { + $output = $this->authorizationOperator->capture( + $this->resolveMethod($payment), + $this->resolveUnifiedApiPaymentId($payment), + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + // Always explicit: without an amount the Unified API captures the ORIGINAL + // authorized amount, not what remains — after a partial capture that is refused as + // a duplicate (execCode 4011, staging 2026-09-24). + $amount, + $payment->getCurrencyCode(), + AuthorizationDetails::fromDetails($payment->getDetails())->version() + 1, + ); + } catch (PayplugException $exception) { + throw $this->refusal($payment, AuthorizationDetails::OPERATION_CAPTURE, $exception); + } + + $this->record($payment, AuthorizationDetails::OPERATION_CAPTURE, $output->status, $output->body, $amount); + + if (null !== $output->maxCaptureDate) { + $payment->setDetails([...$payment->getDetails(), AuthorizationDetails::MAX_CAPTURE_DATE => $output->maxCaptureDate]); + } + + // Durable before anything else can throw, and before runLocked() lets go of the lock. + $this->entityManager->flush(); + } + + /** + * @throws AuthorizationOperationException + */ + private function assertOperable(PaymentInterface $payment, ?int $expectedVersion, string $operation): void + { + if (!self::supports($payment) || !$this->isAuthorized($payment)) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'not_authorized'); + } + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + + if (null !== $expectedVersion && $expectedVersion !== $authorization->version()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'stale'); + } + + if ($authorization->isExpired($this->clock->now())) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'expired'); + } + + if (AuthorizationDetails::OPERATION_CANCELLATION === $operation && $authorization->hasCaptures()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'cancel_after_capture'); + } + + if (0 === $authorization->remainingAmount()) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'nothing_remaining'); + } + } + + /** + * @throws AuthorizationOperationException + */ + private function resolveAmount(PaymentInterface $payment, ?int $amount, int $remaining): int + { + $currencyCode = $payment->getCurrencyCode(); + if (null !== $amount && !self::supportsTypedAmounts($currencyCode)) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'unsupported_currency', [ + '%currency%' => $currencyCode ?? '', + ]); + } + + $amount ??= $remaining; + + if ($amount <= 0) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'invalid_amount'); + } + + if ($amount > $remaining) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'amount_exceeds_remaining', [ + '%remaining%' => self::formatAmount($remaining, (string) $currencyCode), + ]); + } + + return $amount; + } + + /** + * The operation id is what lets HostedFieldsWebhookNotificationHandler recognize this + * operation's own webhook later on (and UnifiedApiIpnAction resolve the payment for it at + * all). Its absence is logged rather than failing the call: the operation did happen, and + * refusing to record it would leave the payment out of step with the transaction. + */ + private function record( + PaymentInterface $payment, + string $operation, + int $status, + string $responseBody, + int $amount, + ): void { + $operationId = self::extractFirstOperationId($responseBody); + if (null === $operationId) { + $this->logger->error('[PayPlug][UPC] Authorization operation succeeded but the response carried no operationIds.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + // Status and execCode only: the body is the API's to shape, keep it out of the logs. + 'status' => $status, + 'exec_code' => self::extractExecCode($responseBody), + ]); + } + + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), $operation, $operationId, $amount)); + + $this->logger->info('[PayPlug][UPC] Authorization operation recorded.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + 'operation_id' => $operationId, + 'amount' => $amount, + ]); + } + + private function refusal( + PaymentInterface $payment, + string $operation, + PayplugException $exception, + ): AuthorizationOperationException { + $this->logger->error('[PayPlug][UPC] Authorization operation refused.', [ + 'sylius_payment_id' => $payment->getId(), + 'operation' => $operation, + 'exception' => $exception::class, + 'error' => $exception->getMessage(), + ]); + + $reason = match (true) { + $exception instanceof AuthorizationExpiredException => 'expired', + $exception instanceof AmountExceedsAvailableException, + $exception instanceof CaptureAmountException, + $exception instanceof CancellationAmountException => 'amount_refused', + $exception instanceof PaymentAlreadyCapturedException => 'already_captured', + $exception instanceof PaymentAlreadyCancelledException => 'already_cancelled', + // Same message from the API whether the payment was already captured or cancelled. + $exception instanceof PaymentNotCapturableException, + $exception instanceof PaymentNotVoidableException => 'not_operable', + $exception instanceof MultipleCaptureNotAllowedException => 'multiple_capture_not_allowed', + $exception instanceof PartialCancellationNotAllowedException => 'partial_cancellation_not_allowed', + $exception instanceof OperationConflictException => 'conflict', + $exception instanceof CardOperationException => 'refused_by_issuer', + $exception instanceof PaymentNotFoundException => 'payment_not_found', + default => 'api_error', + }; + + return new AuthorizationOperationException(self::ERROR_KEY_PREFIX . $reason, [], $exception); + } + + /** + * @throws AuthorizationOperationException + */ + private function runLocked(PaymentInterface $payment, \Closure $operation): void + { + $lockKey = AuthorizationLockKey::forPaymentId($payment->getId()); + if (!$this->lock->acquire($lockKey, self::LOCK_TTL_SECONDS)) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'in_progress'); + } + + try { + $operation(); + } finally { + $this->lock->release($lockKey); + } + } + + private function applyTransition(PaymentInterface $payment, string $transition): void + { + if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->logger->warning('[PayPlug][UPC] Cannot apply payment transition after an authorization operation.', [ + 'sylius_payment_id' => $payment->getId(), + 'current_state' => $payment->getState(), + 'transition' => $transition, + ]); + + return; + } + + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + } + + private function isAuthorized(PaymentInterface $payment): bool + { + return PaymentInterface::STATE_AUTHORIZED === $payment->getState(); + } + + private function resolveMethod(PaymentInterface $payment): PaymentMethodInterface + { + $method = $payment->getMethod(); + if (!$method instanceof PaymentMethodInterface) { + throw new \LogicException('Payment method is not set for the payment.'); + } + + return $method; + } + + private function resolveUnifiedApiPaymentId(PaymentInterface $payment): string + { + $paymentId = $payment->getDetails()['hosted_fields_payment_id'] ?? null; + if (!\is_string($paymentId) || '' === $paymentId) { + throw new AuthorizationOperationException(self::ERROR_KEY_PREFIX . 'payment_not_found'); + } + + return $paymentId; + } + + private static function extractFirstOperationId(string $body): ?string + { + $decoded = \json_decode($body, true); + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + return \is_string($operationId) && '' !== $operationId ? $operationId : null; + } + + private static function extractExecCode(string $body): int|string|null + { + $decoded = \json_decode($body, true); + $execCode = \is_array($decoded) ? ($decoded['execCode'] ?? null) : null; + + return \is_int($execCode) || \is_string($execCode) ? $execCode : null; + } + + /** + * Only reached for an explicit amount, so resolveAmount() has already checked that + * $currencyCode is one supportsTypedAmounts() accepts: a 2-decimal currency. + */ + private static function formatAmount(int $amount, string $currencyCode): string + { + return \trim(\number_format(AmountHelper::fromCents($amount, $currencyCode), 2, '.', ' ') . ' ' . $currencyCode); + } +} diff --git a/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php b/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php index 4b16ee5b..b876a361 100644 --- a/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php +++ b/src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php @@ -4,7 +4,7 @@ namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler; @@ -18,7 +18,7 @@ final class CaptureAuthorizedPaymentProcessor { public function __construct( - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, private PaymentNotificationHandler $paymentNotificationHandler, ) { } diff --git a/src/PaymentProcessing/HostedFieldsCaptureData.php b/src/PaymentProcessing/HostedFieldsCaptureData.php new file mode 100644 index 00000000..201a12b6 --- /dev/null +++ b/src/PaymentProcessing/HostedFieldsCaptureData.php @@ -0,0 +1,24 @@ +logger->info('Hosted Fields token received, awaiting UPC payment processing.', [ + 'payment_id' => $payment->getId(), + 'selected_brand' => $captureData->selectedBrand, + 'save_card' => $captureData->saveCard, + ]); + + $payment->setDetails(\array_merge( + $payment->getDetails(), + [ + 'hosted_fields_token' => $captureData->hfToken, + 'hosted_fields_selected_brand' => $captureData->selectedBrand, + 'hosted_fields_save_card' => $captureData->saveCard, + 'hosted_fields_last4' => $captureData->last4, + 'hosted_fields_expiration_month' => $captureData->expirationMonth, + 'hosted_fields_expiration_year' => $captureData->expirationYear, + 'hosted_fields_country' => $captureData->countryCode, + 'status' => PaymentInterface::STATE_PROCESSING, + ], + )); + } +} diff --git a/src/PaymentProcessing/PaymentTransitionApplier.php b/src/PaymentProcessing/PaymentTransitionApplier.php index a5af2e8b..1836a80f 100644 --- a/src/PaymentProcessing/PaymentTransitionApplier.php +++ b/src/PaymentProcessing/PaymentTransitionApplier.php @@ -24,11 +24,17 @@ public function apply(PaymentInterface $payment): bool $status = $details['status'] ?? ''; // These are known PayPlug statuses that do not map to a Sylius payment transition. - if (\in_array($status, [ - PayPlugApiClientInterface::STATUS_CREATED, - PayPlugApiClientInterface::REFUNDED, - PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK, - ], true)) { + if ( + \in_array( + $status, + [ + PayPlugApiClientInterface::STATUS_CREATED, + PayPlugApiClientInterface::REFUNDED, + PayPlugApiClientInterface::INTERNAL_STATUS_ONE_CLICK, + ], + true, + ) + ) { return false; } @@ -41,23 +47,29 @@ public function apply(PaymentInterface $payment): bool }; if (null === $transition) { - $this->logger->warning('[PayPlug] Cannot apply payment transition: unknown status.', [ + $this->logger->warning( + '[PayPlug] Cannot apply payment transition: unknown status.', + [ 'sylius_payment_id' => $payment->getId(), 'payplug_payment_id' => $details['payment_id'] ?? null, 'status' => $status, - ]); + ], + ); return false; } if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { - $this->logger->warning('[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', [ + $this->logger->warning( + '[PayPlug] Cannot apply payment transition (already applied or incompatible with current state).', + [ 'sylius_payment_id' => $payment->getId(), 'payplug_payment_id' => $details['payment_id'] ?? null, 'current_state' => $payment->getState(), 'transition' => $transition, 'status' => $status, - ]); + ], + ); return false; } diff --git a/src/PaymentProcessing/RefundPaymentProcessor.php b/src/PaymentProcessing/RefundPaymentProcessor.php index 4f9bb13b..1085f0b5 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -16,6 +16,11 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; +use PayPlug\SyliusPayPlugPlugin\Upc\PaymentOrderIdResolver; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundDetailsLockKey; +use PayplugUnifiedCore\Contracts\ILock; use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -26,6 +31,7 @@ use Sylius\RefundPlugin\Entity\RefundPayment; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Workflow\Attribute\AsCompletedListener; use Symfony\Component\Workflow\Event\CompletedEvent; use Symfony\Contracts\Translation\TranslatorInterface; @@ -34,6 +40,8 @@ #[Autoconfigure(public: true)] final class RefundPaymentProcessor implements PaymentProcessorInterface { + private const REFUND_LOCK_TTL_SECONDS = 30; + private PayPlugApiClientInterface $payPlugApiClient; public function __construct( @@ -43,6 +51,8 @@ public function __construct( private RepositoryInterface $refundPaymentRepository, private RefundHistoryRepositoryInterface $payplugRefundHistoryRepository, private PayPlugApiClientFactoryInterface $apiClientFactory, + private RefundCreatorInterface $refundCreator, + private ILock $lock, ) { } @@ -60,6 +70,13 @@ public function onRefundCompleteTransitionEvent(CompletedEvent $event): void public function process(PaymentInterface $payment): void { $this->prepare($payment); + + if (self::isHostedFields($payment)) { + $this->processHostedFields($payment); + + return; + } + $details = $payment->getDetails(); Assert::string($details['payment_id']); @@ -77,6 +94,13 @@ public function process(PaymentInterface $payment): void public function processWithAmount(PaymentInterface $payment, int $amount, int $refundId): void { $this->prepare($payment); + + if (self::isHostedFields($payment)) { + $this->processHostedFieldsWithAmount($payment, $amount, $refundId); + + return; + } + $details = $payment->getDetails(); Assert::string($details['payment_id']); @@ -112,6 +136,345 @@ public function processWithAmount(PaymentInterface $payment, int $amount, int $r } } + /** + * UHF counterpart of process() — same full-refund shape, but via UPC's createRefund() rather + * than the legacy PayPlugApiClient. No RefundHistory bookkeeping here, matching process()'s + * own behavior (only processWithAmount() persists one) — but the refund's own operation id is + * still recorded under $details['refunds'] (internal_id null: there's no Sylius + * RefundPayment/$refundId in this flow), since HostedFieldsWebhookNotificationHandler resolves + * the payment for a refund's async webhook confirmation by matching against ids present + * somewhere in Payment::details — without this, that confirmation could never be resolved. + * + * Guarded by $lock (same ILock contract HostedFieldsWebhookNotificationHandler already uses), + * keyed by RefundDetailsLockKey — the same key processHostedFieldsWithAmount() uses for this + * same payment, so a full and a partial refund triggered concurrently on it serialize against + * each other too, not just two calls of the same kind; it's also the same key + * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() acquires before its own + * write to this same $details['refunds'] array, so a refund creation here (which spans the + * createRefund() network call) and a webhook concurrently flagging an earlier refund failed + * can't interleave their read-modify-write and silently drop one of the two writes. Unlike the + * legacy flow, which forwards Sylius's own refund id to PayPlug as a de-facto idempotency key, + * UPC's createRefund() has no idempotency-key parameter at all — a concurrent second call for + * this same payment (e.g. a double form submission, or a full refund racing a partial one) + * would otherwise be free to trigger a second, real refund on the account. There's no + * RefundHistory/refundId to check-then-act on here (full refunds don't create one, mirroring + * process()'s legacy behavior), so the lock is the only guard available for this path. + */ + private function processHostedFields(PaymentInterface $payment): void + { + $hostedFieldsPaymentId = $this->resolveHostedFieldsPaymentId($payment); + $originalAmount = $payment->getAmount(); + if (null === $originalAmount) { + throw new \LogicException('Payment amount is not set.'); + } + + /** @var PaymentMethodInterface $method */ + $method = $payment->getMethod(); + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + + $this->runLockedRefund($lockKey, ['sylius_payment_id' => $payment->getId()], function () use ($payment, $method, $originalAmount, $hostedFieldsPaymentId): void { + // Re-read now that the lock is held, not a snapshot taken before it: the lock is what + // actually keeps this read-modify-write from racing + // HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed()'s own — see this + // method's own docblock above. + $details = $payment->getDetails(); + $externalId = $this->createRefundOperation( + $method, + $hostedFieldsPaymentId, + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + null, + $payment->getCurrencyCode(), + ['sylius_payment_id' => $payment->getId()], + ); + + $refunds = self::normalizeRefunds($details); + // Omitting $amount to createRefund() above refunds the payment's full REMAINING + // amount (per UnifiedApiPaymentService::createRefund()'s own docblock), not + // $originalAmount — those two only coincide when no prior refund exists yet. + // Subtracting whatever this plugin already recorded as refunded (computed BEFORE + // appending the new entry below) keeps this one accurate, which matchesPayment() then + // relies on to ever match this refund's own webhook confirmation. + // A deferred-capture payment only ever received what was captured — less than its + // own amount once part of the authorization was cancelled. + $authorization = AuthorizationDetails::fromDetails($details); + $receivedAmount = $authorization->isDeferred() ? $authorization->capturedAmount() : $originalAmount; + $refundedAmount = $receivedAmount - self::sumRecordedRefunds($refunds); + self::appendRefundEntry($payment, $details, $refunds, null, $externalId, $refundedAmount); + }); + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): both acquire $lockKey (see + * each method's own docblock for why a lock is needed at all here), run $refund inside it, and + * convert any \Exception it throws — including one from $refundCreator->createRefund() itself + * — into the same logged UpdateHandlingException, releasing the lock either way. + * + * @param mixed[] $lockFailureContext + */ + private function runLockedRefund(string $lockKey, array $lockFailureContext, \Closure $refund): void + { + if (!$this->lock->acquire($lockKey, self::REFUND_LOCK_TTL_SECONDS)) { + $this->logger->error('[PayPlug][UPC] Refund already in progress for this payment, refusing a concurrent call.', $lockFailureContext); + + throw new UpdateHandlingException(); + } + + try { + $refund(); + } catch (Exception $exception) { + $this->logger->error('[PayPlug][UPC] Refund Payment', ['error' => $exception->getMessage()]); + + throw new UpdateHandlingException(); + } finally { + $this->lock->release($lockKey); + } + } + + /** + * Actionable, not just informational: without an operation id, the eventual async webhook + * confirmation for this refund can never be matched back to it (see + * HostedFieldsWebhookNotificationHandler::findMatchingRefundAmount()) and either gets dropped + * or, worse, misapplied as a plain payment confirmation. + * + * @param mixed[] $context + */ + private function logIfOperationIdMissing(?string $externalId, string $responseBody, array $context): void + { + if (null !== $externalId) { + return; + } + + $this->logger->error('[PayPlug][UPC] Refund succeeded but the response carried no operationIds.', [ + ...$context, + 'response_body' => $responseBody, + ]); + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): calls + * $refundCreator->createRefund() and extracts the refund's own operation id from the response + * (logging via logIfOperationIdMissing() when the response carried none) — the one piece + * genuinely identical between the two callers, $amount aside (null here means a full refund; + * a given value means a partial one). + * + * $currency is required rather than defaulted, so that a future caller cannot silently omit it + * and fall back to letting the platform infer $amount's minor units from the account — which is + * only unambiguous for a single-currency merchant. Pass $payment->getCurrencyCode(); it is + * nullable at the Sylius contract level, and null legitimately reaches UPC as "not supplied". + * + * @param mixed[] $logContext + */ + private function createRefundOperation( + PaymentMethodInterface $method, + string $hostedFieldsPaymentId, + string $orderId, + ?int $amount, + ?string $currency, + array $logContext, + ): ?string { + $response = $this->refundCreator->createRefund($method, $hostedFieldsPaymentId, $orderId, $amount, $currency); + + $externalId = self::extractFirstOperationId($response['body']); + $this->logIfOperationIdMissing($externalId, $response['body'], $logContext); + + return $externalId; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + private static function normalizeRefunds(array $details): array + { + $refunds = $details['refunds'] ?? []; + + return \is_array($refunds) ? $refunds : []; + } + + /** + * Shared by processHostedFields()/processHostedFieldsWithAmount(): appends one entry to + * $refunds (already normalized via normalizeRefunds()) and persists the resulting + * $details['refunds'] via setDetails() — the bookkeeping + * HostedFieldsWebhookNotificationHandler later matches a refund's async webhook confirmation + * against (see its own findMatchingRefundAmount()). + * + * @param mixed[] $details + * @param mixed[] $refunds + */ + private static function appendRefundEntry( + PaymentInterface $payment, + array $details, + array $refunds, + ?int $internalId, + ?string $externalId, + int $amount, + ): void { + $refunds[] = [ + 'internal_id' => $internalId, + 'id' => $externalId, + 'amount' => $amount, + ]; + $details['refunds'] = $refunds; + $payment->setDetails($details); + } + + /** + * @param mixed[] $refunds + * + * Skips any entry HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() flagged + * 'failed' => true — its synchronous createRefund() call was accepted (2xx), but the async + * confirmation later reported the refund itself never actually completed, so that money was + * never moved and must not count against the remaining balance a subsequent full refund + * derives from this sum. + */ + private static function sumRecordedRefunds(array $refunds): int + { + $total = 0; + foreach ($refunds as $refund) { + if (!\is_array($refund) || true === ($refund['failed'] ?? false)) { + continue; + } + + $amount = $refund['amount'] ?? null; + $total += \is_int($amount) ? $amount : 0; + } + + return $total; + } + + /** + * UHF counterpart of processWithAmount(). The refund's own operation id — extracted from the + * createRefund() response's operationIds[0], same convention resolveHostedFieldsIds() uses + * for a payment's operationIds[0] at creation time — takes the place of the legacy SDK + * Refund object's ->id in $details['refunds']. RefundHistory::externalId stays null, exactly + * like the legacy flow: that field is reserved for the id an async webhook confirmation would + * carry, not the id already known synchronously here. + * + * Unlike the legacy flow (which forwards Sylius's own $refundId to the API as a de-facto + * idempotency key), UPC's createRefund() has no idempotency-key parameter at all — so, on top + * of a RefundHistory already recorded for this $refundId being checked for up front (before + * calling createRefund() again), the whole check-then-act sequence is now also guarded by + * $lock, keyed by RefundDetailsLockKey (same key processHostedFields() uses for this same + * payment, so a partial and a full refund racing each other also serialize, not just two + * partial refunds — and the same key HostedFieldsWebhookNotificationHandler acquires around + * its own write to this $details['refunds'] array, see processHostedFields()'s own docblock): + * without it, two concurrent calls could both pass the RefundHistory check before either one + * persists, and both would go on to call createRefund() — double-refunding money that a plain + * "check first" can't prevent, only a lock actually serializing the attempts can. + */ + private function processHostedFieldsWithAmount(PaymentInterface $payment, int $amount, int $refundId): void + { + $hostedFieldsPaymentId = $this->resolveHostedFieldsPaymentId($payment); + + /** @var PaymentMethodInterface $method */ + $method = $payment->getMethod(); + $lockKey = RefundDetailsLockKey::forPaymentId($payment->getId()); + + $this->runLockedRefund( + $lockKey, + ['sylius_payment_id' => $payment->getId(), 'refund_id' => $refundId], + function () use ($payment, $method, $amount, $refundId, $hostedFieldsPaymentId): void { + /** @var RefundPayment $refundPayment */ + $refundPayment = $this->refundPaymentRepository->findOneBy(['id' => $refundId]); + + if ($this->payplugRefundHistoryRepository->findOneBy(['refundPayment' => $refundPayment]) instanceof RefundHistory) { + $this->logger->info('[PayPlug][UPC] Refund already recorded for this refund id, skipping duplicate call.', ['refund_id' => $refundId]); + + return; + } + + // Re-read now that the lock is held — see processHostedFields()'s own comment on + // its equivalent re-read. + $details = $payment->getDetails(); + $externalId = $this->createRefundOperation( + $method, + $hostedFieldsPaymentId, + PaymentOrderIdResolver::resolve($payment->getOrder(), $payment->getId()), + $amount, + $payment->getCurrencyCode(), + ['refund_id' => $refundId], + ); + + self::appendRefundEntry($payment, $details, self::normalizeRefunds($details), $refundId, $externalId, $amount); + + $refundHistory = new RefundHistory(); + $refundHistory + ->setExternalId(null) + ->setPayment($payment) + ->setRefundPayment($refundPayment) + ->setValue($amount) + ->setProcessed(true) + ; + $this->payplugRefundHistoryRepository->add($refundHistory); + }, + ); + } + + /** + * Refunds are keyed by the payment's own id; the refund route refuses the operation id ("The + * reference transaction has not been found."), so there is nothing to fall back on. Refuses + * before the lock, the API call and any bookkeeping, tells the admin and logs ids only. A + * stored id is never changed afterwards, so the value read here is the one used under the lock. + */ + private function resolveHostedFieldsPaymentId(PaymentInterface $payment): string + { + $details = $payment->getDetails(); + $paymentId = $details['hosted_fields_payment_id'] ?? null; + if (\is_string($paymentId) && '' !== $paymentId) { + return $paymentId; + } + + $orderNumber = $payment->getOrder()?->getNumber(); + $this->logger->error('[PayPlug][UPC] Refund refused: the payment has no hosted_fields_payment_id.', [ + 'sylius_payment_id' => $payment->getId(), + 'order_number' => $orderNumber, + 'hosted_fields_operation_id' => $details['hosted_fields_operation_id'] ?? null, + ]); + $this->notifyAdminRefundUnavailable($orderNumber); + + throw new UpdateHandlingException(); + } + + /** + * No main request or no session (CLI, worker) means there is no admin to tell; the log entry + * and the exception still record the refusal. + */ + private function notifyAdminRefundUnavailable(?string $orderNumber): void + { + $request = $this->requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + return; + } + + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('error', $this->translator->trans( + 'payplug_sylius_payplug_plugin.ui.payment_refund_missing_payment_id', + ['%order%' => $orderNumber ?? ''], + )); + } + + private static function extractFirstOperationId(string $body): ?string + { + $decoded = \json_decode($body, true); + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + return \is_string($operationId) && '' !== $operationId ? $operationId : null; + } + + private static function isHostedFields(PaymentInterface $payment): bool + { + /** @var PaymentMethodInterface $paymentMethod */ + $paymentMethod = $payment->getMethod(); + + return PayPlugGatewayFactory::isHostedFieldsConfig($paymentMethod->getGatewayConfig()); + } + private function prepare(PaymentInterface $payment): void { /** @var PaymentMethodInterface $paymentMethod */ @@ -133,6 +496,16 @@ private function prepare(PaymentInterface $payment): void return; } + // UHF has no "payment_id" detail (see resolveHostedFieldsIds() in + // CaptureHostedPaymentRequestHandler — it stores hosted_fields_payment_id instead), so the + // check below would otherwise misfire the "refunded locally only" flash message on every + // UHF refund even though process()/processWithAmount() now genuinely call the Unified API + // for it. The legacy $payPlugApiClient this method would otherwise build below is unused + // by the UHF path, so skipping it here also avoids an unnecessary token mint. + if (self::isHostedFields($payment)) { + return; + } + if (!isset($details['payment_id'])) { $this->requestStack->getSession()->getFlashBag()->add( 'info', diff --git a/src/Provider/OneySimulation/CachedSimulationDataProvider.php b/src/Provider/OneySimulation/CachedSimulationDataProvider.php index c0b5f4ca..95935cf6 100644 --- a/src/Provider/OneySimulation/CachedSimulationDataProvider.php +++ b/src/Provider/OneySimulation/CachedSimulationDataProvider.php @@ -25,11 +25,14 @@ public function __construct( public function getForCart(OrderInterface $cart): array { $country = \explode('_', $cart->getLocaleCode() ?? 'fr_FR')[1]; + // The channel is part of the key because `fees_for` is resolved from the Oney gateway + // config serving the *current* channel (PRE-3440), and each channel may run its own. $cacheKey = \sprintf( - 'oney_simulation_%s_%s_%s', + 'oney_simulation_%s_%s_%s_%s', $country, $cart->getTotal(), $this->oneySupportedPaymentChoiceProvider->getFeesFor(), + $cart->getChannel()?->getCode() ?? 'no_channel', ); return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cart): array { diff --git a/src/Provider/OneySupportedPaymentChoiceProvider.php b/src/Provider/OneySupportedPaymentChoiceProvider.php index 9635b7b3..10b62938 100644 --- a/src/Provider/OneySupportedPaymentChoiceProvider.php +++ b/src/Provider/OneySupportedPaymentChoiceProvider.php @@ -6,13 +6,18 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; +use Sylius\Component\Channel\Context\ChannelContextInterface; +use Sylius\Component\Channel\Context\ChannelNotFoundException; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; class OneySupportedPaymentChoiceProvider { - public function __construct(private PaymentMethodRepositoryInterface $paymentMethodRepository) - { + public function __construct( + private PaymentMethodRepositoryInterface $paymentMethodRepository, + private ChannelContextInterface $channelContext, + ) { } public function getSupportedPaymentChoices(bool $useOneyPrefix = false): array @@ -37,9 +42,14 @@ public function getFeesFor(): string return $this->getPaymentGatewayConfig()['fees_for'] ?? ''; } + /** + * Scoped to the current channel: `fees_for` drives which instalment choices are offered to the + * shopper, and since PRE-3628 each channel may run its own Oney gateway config with its own + * value. A factory-name-only lookup would show one channel's instalment plans on another's. + */ private function getPaymentGatewayConfig(): array { - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(OneyGatewayFactory::FACTORY_NAME); + $paymentMethod = $this->resolveOneyPaymentMethod(); if (!$paymentMethod instanceof PaymentMethodInterface) { return []; @@ -50,4 +60,27 @@ private function getPaymentGatewayConfig(): array return $gateway->getConfig(); } + + /** + * `getFeesFor()` is called outside the try/catch in `getSupportedPaymentChoices()`, so this + * absorbs the no-channel case rather than letting it escape — there being no current channel is + * an ordinary state (CLI, an admin request), not an error. + */ + private function resolveOneyPaymentMethod(): ?PaymentMethodInterface + { + try { + $channel = $this->channelContext->getChannel(); + } catch (ChannelNotFoundException) { + return null; + } + + if (!$channel instanceof ChannelInterface) { + return null; + } + + return $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + OneyGatewayFactory::FACTORY_NAME, + $channel, + ); + } } diff --git a/src/Provider/Payment/ApplePayPaymentProvider.php b/src/Provider/Payment/ApplePayPaymentProvider.php index 96592302..82e875c4 100644 --- a/src/Provider/Payment/ApplePayPaymentProvider.php +++ b/src/Provider/Payment/ApplePayPaymentProvider.php @@ -27,6 +27,7 @@ use Sylius\Component\Core\OrderPaymentTransitions; use Sylius\Component\Core\Payment\Exception\NotProvidedOrderPaymentException; use Sylius\Component\Core\TokenAssigner\OrderTokenAssignerInterface; +use Sylius\Component\Customer\Model\CustomerInterface; use Sylius\Component\Payment\Factory\PaymentFactoryInterface; use Sylius\Component\Payment\PaymentTransitions; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -49,16 +50,16 @@ public function __construct( public function provide(Request $request, OrderInterface $order): PaymentInterface { - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(ApplePayGatewayFactory::FACTORY_NAME); + $paymentMethod = $this->resolveApplePayPaymentMethod($order); - if (!$paymentMethod instanceof PaymentMethodInterface || !$paymentMethod->isEnabled()) { + if (!$paymentMethod instanceof PaymentMethodInterface) { throw new LogicException('Apple Pay is not enabled'); } - $payment = $this->initApplePaySyliusPaymentState($order); + $payment = $this->initApplePaySyliusPaymentState($order, $paymentMethod); Assert::notNull($order->getBillingAddress()); - if (($customer = $order->getBillingAddress()->getCustomer()) instanceof \Sylius\Component\Customer\Model\CustomerInterface) { + if (($customer = $order->getBillingAddress()->getCustomer()) instanceof CustomerInterface) { $order->setCustomer($customer); } @@ -198,21 +199,45 @@ public function cancel(OrderInterface $order): void } /** + * Takes the method resolved by the caller rather than resolving it again: provide() has already + * rejected the null case, so accepting it as a non-nullable parameter keeps that single gate the + * only place the "Apple Pay is not enabled" decision is made. + * * @throws NotProvidedOrderPaymentException */ - private function initApplePaySyliusPaymentState(OrderInterface $order): PaymentInterface - { + private function initApplePaySyliusPaymentState( + OrderInterface $order, + PaymentMethodInterface $paymentMethod, + ): PaymentInterface { Assert::notNull($order->getCurrencyCode()); $payment = $this->getPayment($order); - $paymentMethod = $this->paymentMethodRepository->findOneByGatewayName(ApplePayGatewayFactory::FACTORY_NAME); $payment->setMethod($paymentMethod); $order->addPayment($payment); return $payment; } + /** + * The enabled Apple Pay method serving the order's own channel. Since PRE-3628 several Apple + * Pay configs may coexist, one per channel and each connected to a different PayPlug account, + * so resolving by factory name alone would attach an arbitrary channel's method to the payment. + */ + private function resolveApplePayPaymentMethod(OrderInterface $order): ?PaymentMethodInterface + { + $channel = $order->getChannel(); + + if (!$channel instanceof ChannelInterface) { + return null; + } + + return $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + ApplePayGatewayFactory::FACTORY_NAME, + $channel, + ); + } + private function getPayment(OrderInterface $order): PaymentInterface { $lastPayment = $order->getLastPayment(); diff --git a/src/Provider/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index 352665db..f285683b 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -5,28 +5,50 @@ namespace PayPlug\SyliusPayPlugPlugin\Provider; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; +use Psr\Log\LoggerInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Webmozart\Assert\Assert; +use Webmozart\Assert\InvalidArgumentException; final class SupportedMethodsProvider { public function __construct( private CurrencyContextInterface $currencyContext, private PayPlugApiClientFactoryInterface $clientFactory, + private AccountAmountRangeResolver $amountRangeResolver, + private LoggerInterface $logger, ) { } + /** + * $paymentCurrencyCode must be the currency $paymentAmount is denominated in — i.e. the + * payment's own, which Sylius copies from the order (OrderPaymentProcessor sets both amount and + * currency from $order). Passing it in rather than reading CurrencyContextInterface here is + * what keeps the two halves of every comparison below in the same currency: the context holds + * the currency being *displayed* right now, which on a multi-currency channel need not be the + * one the order was placed in — and comparing an amount against another currency's min/max + * silently filters the wrong methods. + * + * The context remains the fallback for a payment with no currency of its own, which the Sylius + * contract permits (PaymentInterface::getCurrencyCode() is nullable) even though the checkout + * flow always sets one. + */ public function provide( array $supportedMethods, string $factoryName, int $paymentAmount, + ?string $paymentCurrencyCode = null, ?string $billingCountryCode = null, ): array { - $activeCurrencyCode = $this->currencyContext->getCurrencyCode(); - $authorizedCurrencies = null; - $allowedCountries = null; + $activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode(); + + /** @var array> $accounts */ + $accounts = []; foreach ($supportedMethods as $key => $paymentMethod) { Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); @@ -38,8 +60,11 @@ public function provide( continue; } - $authorizedCurrencies ??= $this->resolveAuthorizedCurrencies($factoryName); - $allowedCountries ??= $this->resolveAllowedCountries($factoryName); + $memoKey = $this->accountMemoKey($gatewayConfig); + $account = $accounts[$memoKey] ??= $this->clientFactory->createForPaymentMethod($paymentMethod)->getAccount(); + + $authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $gatewayConfig); + $allowedCountries = $this->resolveAllowedCountries($account, $gatewayConfig); if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) { unset($supportedMethods[$key]); @@ -48,70 +73,146 @@ public function provide( } if (!\array_key_exists($activeCurrencyCode, $authorizedCurrencies)) { - unset($supportedMethods[$key]); - + // Unified Hosted Fields is exempt from the currency gate, pending a source of truth + // that actually knows a UHF account's currencies. The Retail `/account` payload only + // advertises the legacy acquiring setup (`configuration.min_amounts` / + // `max_amounts`), which is demonstrably wrong for a UHF account: account 1461487 + // reports `currencies: ['EUR']` while a USD payment on that same account completed + // on staging (schemeTransactionId 6a9ad8eb905d3). Hiding a method that works is the + // worse failure, so UHF is kept and left to the API to accept or refuse. + // + // Every other gateway keeps the gate: Bancontact, Scalapay, Apple Pay, American + // Express and Wero are EUR-only, and `payplug` in integrated_payment mode is served + // by the very legacy setup this payload does describe correctly. + if (!PayPlugGatewayFactory::isHostedFieldsConfig($gatewayConfig)) { + unset($supportedMethods[$key]); + } + + // An unadvertised currency carries no amount limits either, so a method kept above + // skips the bounds check rather than indexing a missing key. continue; } - if ( - $paymentAmount < $authorizedCurrencies[$activeCurrencyCode]['min_amount'] || - $paymentAmount > $authorizedCurrencies[$activeCurrencyCode]['max_amount'] - ) { - unset($supportedMethods[$key]); + [$minAmount, $maxAmount] = $this->resolveAmountBounds( + $gatewayConfig, + $activeCurrencyCode, + $authorizedCurrencies[$activeCurrencyCode], + ); - continue; + if ($paymentAmount < $minAmount || $paymentAmount > $maxAmount) { + unset($supportedMethods[$key]); } } return $supportedMethods; } - private function resolveAuthorizedCurrencies(string $factoryName): array - { - $account = $this->clientFactory->create($factoryName)->getAccount(); + /** + * ScalapayGatewayConfigurationTypeExtension lets the merchant tighten the API-provided bounds + * via the min_amount/max_amount config keys. The override is deliberately scoped to Scalapay: + * IsScalapayAmountRangeValidValidator — the save-time guardrail that keeps the configured + * range inside what PayPlug authorizes — is wired for Scalapay only, so honouring the same + * keys on another gateway would grant it a checkout override with no validation behind it. + * The values are entered in EUR, so the override also only applies to an EUR checkout; other + * currencies keep the raw API bounds. + * + * @param array{min_amount: int, max_amount: int} $authorizedRange + * + * @return array{0: int, 1: int} + */ + private function resolveAmountBounds( + GatewayConfigInterface $gatewayConfig, + string $activeCurrencyCode, + array $authorizedRange, + ): array { + if ('EUR' !== $activeCurrencyCode || ScalapayGatewayFactory::FACTORY_NAME !== $gatewayConfig->getFactoryName()) { + return [$authorizedRange['min_amount'], $authorizedRange['max_amount']]; + } - $configuration = $account['configuration'] ?? []; - Assert::isArray($configuration); - $defaultMin = $configuration['min_amounts'] ?? []; - Assert::isArray($defaultMin); - $defaultMax = $configuration['max_amounts'] ?? []; - Assert::isArray($defaultMax); + [$minAmount, $maxAmount] = $this->readConfiguredAmounts($gatewayConfig->getConfig()); - $underscorePos = strpos($factoryName, '_'); - if ($underscorePos !== false) { - $pmKey = substr($factoryName, $underscorePos + 1); - $paymentMethods = $account['payment_methods'] ?? []; - Assert::isArray($paymentMethods); - $pmData = $paymentMethods[$pmKey] ?? []; - Assert::isArray($pmData); - $minAmounts = isset($pmData['min_amounts']) && \is_array($pmData['min_amounts']) ? $pmData['min_amounts'] : $defaultMin; - $maxAmounts = isset($pmData['max_amounts']) && \is_array($pmData['max_amounts']) ? $pmData['max_amounts'] : $defaultMax; - } else { - $minAmounts = $defaultMin; - $maxAmounts = $defaultMax; + return [ + $minAmount ?? $authorizedRange['min_amount'], + $maxAmount ?? $authorizedRange['max_amount'], + ]; + } + + /** + * The admin form only ever writes null or an int, but the gateway config is a plain serialized + * array that a direct DB edit, an import script or an admin API write can leave anything in. + * provide() runs unguarded on every checkout page (via the gateway resolver decorators), so a + * malformed value degrades to "not configured" — falling back to the API bounds — rather than + * throwing an assertion error that would break payment-method resolution for the whole + * checkout, not just hide Scalapay. + * + * @param array $config + * + * @return array{0: int|null, 1: int|null} + */ + private function readConfiguredAmounts(array $config): array + { + $minAmount = $config[ScalapayGatewayFactory::MIN_AMOUNT] ?? null; + $maxAmount = $config[ScalapayGatewayFactory::MAX_AMOUNT] ?? null; + + try { + Assert::nullOrInteger($minAmount); + Assert::nullOrInteger($maxAmount); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('Ignoring malformed Scalapay amount range in gateway config; falling back to the PayPlug API bounds.', [ + 'min_amount' => $minAmount, + 'max_amount' => $maxAmount, + 'exception' => $exception->getMessage(), + ]); + + return [null, null]; } - $currencies = []; - foreach ($minAmounts as $currency => $min) { - Assert::string($currency); - Assert::integer($min); - if (isset($maxAmounts[$currency]) && \is_int($maxAmounts[$currency])) { - $currencies[$currency] = ['min_amount' => $min, 'max_amount' => $maxAmounts[$currency]]; - } + return [$minAmount, $maxAmount]; + } + + /** + * Two payment methods of the same factory can be configured on different PayPlug accounts, so + * the `/account` payload is memoized per gateway config rather than once per call — sharing one + * lookup across the loop let the first method's account govern every later one. The persisted + * id is the key; object identity covers a config that has not been flushed yet, whose null id + * would otherwise collide with every other unsaved one. + */ + private function accountMemoKey(GatewayConfigInterface $gatewayConfig): string + { + $id = $gatewayConfig->getId(); + + if (\is_int($id) || (\is_string($id) && '' !== $id)) { + return 'config:' . $id; } - return $currencies; + return 'object:' . spl_object_id($gatewayConfig); } - private function resolveAllowedCountries(string $factoryName): array + /** + * Both resolvers below read the factory name off the gateway config the $account was fetched + * for, rather than off provide()'s $factoryName argument. The loop guard above makes the two + * equal today, but keeping the account payload and the key used to index it sourced from the + * same config is what stops the pair drifting apart if that guard is ever relaxed. + * + * @param array $account + * + * @return array + */ + private function resolveAuthorizedCurrencies(array $account, GatewayConfigInterface $gatewayConfig): array { - $underscorePos = strpos($factoryName, '_'); - if ($underscorePos === false) { + return $this->amountRangeResolver->resolve($account, $this->paymentMethodKey($gatewayConfig)); + } + + /** + * @param array $account + */ + private function resolveAllowedCountries(array $account, GatewayConfigInterface $gatewayConfig): array + { + $pmKey = $this->paymentMethodKey($gatewayConfig); + if (null === $pmKey) { return []; } - $account = $this->clientFactory->create($factoryName)->getAccount(); - $pmKey = substr($factoryName, $underscorePos + 1); $paymentMethods = $account['payment_methods'] ?? []; Assert::isArray($paymentMethods); $pmData = $paymentMethods[$pmKey] ?? []; @@ -126,4 +227,20 @@ private function resolveAllowedCountries(string $factoryName): array return $allowedCountries; } + + /** + * The `/account` payload keys each PPRO method under the factory name's suffix — `payplug_oney` + * is advertised as `oney`. A suffix-less factory name (`payplug`) is the card gateway, which + * has no such sub-payload. + */ + private function paymentMethodKey(GatewayConfigInterface $gatewayConfig): ?string + { + // provide()'s loop guard has already matched this config against a non-null factory name, + // so the null coalesce is unreachable from there; it keeps the helper total for any later + // caller, and an empty name carries no suffix anyway. + $factoryName = $gatewayConfig->getFactoryName() ?? ''; + $underscorePos = strpos($factoryName, '_'); + + return false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null; + } } diff --git a/src/Repository/PaymentMethodRepository.php b/src/Repository/PaymentMethodRepository.php index d2e12daa..70d10524 100644 --- a/src/Repository/PaymentMethodRepository.php +++ b/src/Repository/PaymentMethodRepository.php @@ -4,20 +4,79 @@ namespace PayPlug\SyliusPayPlugPlugin\Repository; +use Doctrine\ORM\QueryBuilder; use Sylius\Bundle\CoreBundle\Doctrine\ORM\PaymentMethodRepository as BasePaymentMethodRepository; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; +use Webmozart\Assert\Assert; final class PaymentMethodRepository extends BasePaymentMethodRepository implements PaymentMethodRepositoryInterface { public function findOneByGatewayName(string $gatewayFactoryName): ?PaymentMethodInterface + { + $paymentMethod = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($paymentMethod, PaymentMethodInterface::class); + + return $paymentMethod; + } + + public function findOneEnabledByGatewayNameAndChannel( + string $gatewayFactoryName, + ChannelInterface $channel, + ): ?PaymentMethodInterface { + $paymentMethod = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->innerJoin('o.channels', 'channel') + ->andWhere('channel = :channel') + ->andWhere('o.enabled = true') + ->setParameter('channel', $channel) + // PRE-3628 admits at most one enabled config per channel per factory, so this orders a + // set of one. It only matters if some non-form write path ever breaks that invariant, + // where picking deterministically beats picking arbitrarily. + ->addOrderBy('o.position', 'ASC') + ->addOrderBy('o.id', 'ASC') + ->getQuery() + ->setMaxResults(1) + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($paymentMethod, PaymentMethodInterface::class); + + return $paymentMethod; + } + + public function findEnabledByGatewayName(string $gatewayFactoryName): array + { + $paymentMethods = $this->createGatewayFactoryQueryBuilder($gatewayFactoryName) + ->leftJoin('o.channels', 'channel') + ->addSelect('channel') + ->andWhere('o.enabled = true') + ->getQuery() + ->getResult() + ; + + Assert::isList($paymentMethods); + Assert::allIsInstanceOf($paymentMethods, PaymentMethodInterface::class); + + return $paymentMethods; + } + + /** + * The shared head of every lookup here: payment methods whose gateway config names $factoryName. + * + * Each caller narrows it further — by channel, by enabled, or not at all — so the join, the + * predicate and the bound parameter live in one place rather than being repeated three times. + */ + private function createGatewayFactoryQueryBuilder(string $gatewayFactoryName): QueryBuilder { return $this->createQueryBuilder('o') ->innerJoin('o.gatewayConfig', 'gatewayConfig') - ->where('gatewayConfig.factoryName = :gatewayFactoryName') + ->andWhere('gatewayConfig.factoryName = :gatewayFactoryName') ->setParameter('gatewayFactoryName', $gatewayFactoryName) - ->getQuery() - ->setMaxResults(1) - ->getSingleResult() ; } } diff --git a/src/Repository/PaymentMethodRepositoryInterface.php b/src/Repository/PaymentMethodRepositoryInterface.php index 54f8c2e9..259a8908 100644 --- a/src/Repository/PaymentMethodRepositoryInterface.php +++ b/src/Repository/PaymentMethodRepositoryInterface.php @@ -4,10 +4,34 @@ namespace PayPlug\SyliusPayPlugPlugin\Repository; +use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Core\Repository\PaymentMethodRepositoryInterface as BasePaymentMethodRepositoryInterface; interface PaymentMethodRepositoryInterface extends BasePaymentMethodRepositoryInterface { + /** + * @deprecated since PRE-3440. Several gateway configs may share a factory name — one per + * channel — so this returns an arbitrary one of them. Use + * {@see self::findOneEnabledByGatewayNameAndChannel()} wherever a channel is in + * scope. + */ public function findOneByGatewayName(string $gatewayFactoryName): ?PaymentMethodInterface; + + /** + * The enabled payment method of that factory serving $channel, or null if the channel has none. + * + * This is the channel-safe replacement for {@see self::findOneByGatewayName()}: since PRE-3628 + * a merchant may run several configs of one factory, each connected to a different PayPlug + * account, and a name-only lookup picks between them arbitrarily. + */ + public function findOneEnabledByGatewayNameAndChannel( + string $gatewayFactoryName, + ChannelInterface $channel, + ): ?PaymentMethodInterface; + + /** + * @return list + */ + public function findEnabledByGatewayName(string $gatewayFactoryName): array; } diff --git a/src/Resolver/AccountAmountRangeResolver.php b/src/Resolver/AccountAmountRangeResolver.php new file mode 100644 index 00000000..91271cab --- /dev/null +++ b/src/Resolver/AccountAmountRangeResolver.php @@ -0,0 +1,53 @@ + $account + * + * @return array + */ + public function resolve(array $account, ?string $paymentMethodKey): array + { + $configuration = $account['configuration'] ?? []; + Assert::isArray($configuration); + $defaultMinAmounts = $configuration['min_amounts'] ?? []; + Assert::isArray($defaultMinAmounts); + $defaultMaxAmounts = $configuration['max_amounts'] ?? []; + Assert::isArray($defaultMaxAmounts); + + if (null !== $paymentMethodKey) { + $paymentMethods = $account['payment_methods'] ?? []; + Assert::isArray($paymentMethods); + $pmData = $paymentMethods[$paymentMethodKey] ?? []; + Assert::isArray($pmData); + $minAmounts = isset($pmData['min_amounts']) && \is_array($pmData['min_amounts']) ? $pmData['min_amounts'] : $defaultMinAmounts; + $maxAmounts = isset($pmData['max_amounts']) && \is_array($pmData['max_amounts']) ? $pmData['max_amounts'] : $defaultMaxAmounts; + } else { + $minAmounts = $defaultMinAmounts; + $maxAmounts = $defaultMaxAmounts; + } + + $currencies = []; + foreach ($minAmounts as $currency => $min) { + Assert::string($currency); + Assert::integer($min); + if (isset($maxAmounts[$currency]) && \is_int($maxAmounts[$currency])) { + $currencies[$currency] = ['min_amount' => $min, 'max_amount' => $maxAmounts[$currency]]; + } + } + + return $currencies; + } +} diff --git a/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php index d525bf61..8f421ca8 100644 --- a/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php +++ b/src/Resolver/AmericanExpressPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, AmericanExpressGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php index 5205eaf7..99cb874c 100644 --- a/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php +++ b/src/Resolver/ApplePayPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, ApplePayGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php index c003b444..56ad0753 100644 --- a/src/Resolver/BancontactPaymentMethodsResolverDecorator.php +++ b/src/Resolver/BancontactPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, BancontactGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/OneyPaymentMethodsResolverDecorator.php b/src/Resolver/OneyPaymentMethodsResolverDecorator.php index 73802220..bb8bde70 100644 --- a/src/Resolver/OneyPaymentMethodsResolverDecorator.php +++ b/src/Resolver/OneyPaymentMethodsResolverDecorator.php @@ -36,7 +36,11 @@ public function getSupportedMethods(BasePaymentInterface $subject): array /** @var OrderInterface $order */ $order = $subject->getOrder(); - $activeCurrencyCode = $this->currencyContext->getCurrencyCode(); + // The payment's own currency, not the one currently displayed: isPriceEligible() below + // compares $subject->getAmount() against Oney's per-currency bounds, and on a + // multi-currency channel the display currency need not be the order's. Falls back to the + // context for a payment carrying no currency, which the Sylius contract allows. + $activeCurrencyCode = $subject->getCurrencyCode() ?? $this->currencyContext->getCurrencyCode(); foreach ($supportedMethods as $key => $paymentMethod) { Assert::isInstanceOf($paymentMethod, PaymentMethodInterface::class); diff --git a/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php index 39412fa1..67d652da 100644 --- a/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php +++ b/src/Resolver/PayPlugPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, PayPlugGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/PaymentStateResolver.php b/src/Resolver/PaymentStateResolver.php index 1c8de161..9d749928 100644 --- a/src/Resolver/PaymentStateResolver.php +++ b/src/Resolver/PaymentStateResolver.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use Payplug\Resource\Payment; use Payplug\Resource\PaymentAuthorization; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\PaymentInterface; @@ -19,7 +19,7 @@ final class PaymentStateResolver implements PaymentStateResolverInterface { public function __construct( private StateMachineInterface $stateMachine, - private PayPlugApiClientFactory $payPlugApiClientFactory, + private PayPlugApiClientFactoryInterface $payPlugApiClientFactory, private EntityManagerInterface $paymentEntityManager, ) { } diff --git a/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php index 05664c39..a3856c30 100644 --- a/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php +++ b/src/Resolver/ScalapayPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, ScalapayGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Resolver/SelectedCardResolver.php b/src/Resolver/SelectedCardResolver.php new file mode 100644 index 00000000..0c43d88b --- /dev/null +++ b/src/Resolver/SelectedCardResolver.php @@ -0,0 +1,36 @@ +requestStack->getSession()->get('payplug_payment_method'); + if (null === $cardId || PayPlugGatewayFactory::CARD_CHOICE_OTHER === $cardId) { + return null; + } + + $card = $this->payplugCardRepository->find($cardId); + + return $card instanceof Card ? $card : null; + } +} diff --git a/src/Resolver/WeroPaymentMethodsResolverDecorator.php b/src/Resolver/WeroPaymentMethodsResolverDecorator.php index 0c8b5601..8c342934 100644 --- a/src/Resolver/WeroPaymentMethodsResolverDecorator.php +++ b/src/Resolver/WeroPaymentMethodsResolverDecorator.php @@ -37,7 +37,8 @@ public function getSupportedMethods(BasePaymentInterface $subject): array $supportedMethods, WeroGatewayFactory::FACTORY_NAME, $subject->getAmount() ?? 0, - $billingCountryCode, + paymentCurrencyCode: $subject->getCurrencyCode(), + billingCountryCode: $billingCountryCode, ); } diff --git a/src/Twig/AuthorizationExtension.php b/src/Twig/AuthorizationExtension.php new file mode 100644 index 00000000..ff67c6bf --- /dev/null +++ b/src/Twig/AuthorizationExtension.php @@ -0,0 +1,88 @@ +describe(...)), + ]; + } + + /** + * @return array{ + * authorized_amount: int, + * captured_amount: int, + * cancelled_amount: int, + * remaining_amount: int, + * remaining_amount_input: string, + * max_capture_date: \DateTimeImmutable|null, + * expired: bool, + * expiring_soon: bool, + * can_capture: bool, + * can_cancel: bool, + * version: int, + * }|null null for any payment that isn't a deferred-capture Hosted Fields one + */ + public function describe(PaymentInterface $payment): ?array + { + if (!AuthorizedPaymentOperationProcessor::supports($payment)) { + return null; + } + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + $now = $this->clock->now(); + + return [ + 'authorized_amount' => $authorization->authorizedAmount(), + 'captured_amount' => $authorization->capturedAmount(), + 'cancelled_amount' => $authorization->cancelledAmount(), + 'remaining_amount' => $authorization->remainingAmount(), + 'remaining_amount_input' => self::remainingAmountInput($authorization->remainingAmount(), $payment->getCurrencyCode()), + 'max_capture_date' => $authorization->maxCaptureDate(), + 'expired' => $authorization->isExpired($now), + 'expiring_soon' => $authorization->isExpiringSoon($now), + 'can_capture' => $this->processor->canCapture($payment), + 'can_cancel' => $this->processor->canCancel($payment), + 'version' => $authorization->version(), + ]; + } + + /** + * What the amount fields' placeholder shows: the same decimal notation they accept. Empty for a + * currency in which a typed amount would be refused (see + * AuthorizedPaymentOperationProcessor::supportsTypedAmounts()), which is 2-decimal otherwise. + */ + private static function remainingAmountInput(int $remainingAmount, ?string $currencyCode): string + { + if (null === $currencyCode || !AuthorizedPaymentOperationProcessor::supportsTypedAmounts($currencyCode)) { + return ''; + } + + return \number_format(AmountHelper::fromCents($remainingAmount, $currencyCode), 2, '.', ''); + } +} diff --git a/src/Twig/OneyExtension.php b/src/Twig/OneyExtension.php index e7952400..53513245 100644 --- a/src/Twig/OneyExtension.php +++ b/src/Twig/OneyExtension.php @@ -6,17 +6,17 @@ use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Repository\PaymentMethodRepositoryInterface; use Sylius\Component\Channel\Context\ChannelContextInterface; -use Sylius\Component\Core\Model\PaymentMethod; -use Sylius\Component\Resource\Repository\RepositoryInterface; +use Sylius\Component\Core\Model\ChannelInterface; +use Sylius\Component\Core\Model\PaymentMethodInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; final class OneyExtension extends AbstractExtension { public function __construct( - private RepositoryInterface $gatewayConfigRepository, - private RepositoryInterface $paymentMethodRepository, + private PaymentMethodRepositoryInterface $paymentMethodRepository, private ChannelContextInterface $channelContext, private OneyChecker $oneyChecker, ) { @@ -29,22 +29,26 @@ public function getFunctions(): array ]; } + /** + * Resolved in one channel-scoped query rather than "find any Oney gateway config, then check + * it happens to serve this channel". Since PRE-3628 a merchant may run one Oney config per + * channel; the old shape picked an arbitrary one and then usually rejected it, hiding Oney on + * every channel but whichever the database returned first. + */ public function isOneyEnabled(): bool { - /** @var \Sylius\Bundle\PayumBundle\Model\GatewayConfig|null $gateway */ - $gateway = $this->gatewayConfigRepository->findOneBy(['factoryName' => OneyGatewayFactory::FACTORY_NAME]); - if (null === $gateway) { - return false; - } + $channel = $this->channelContext->getChannel(); - /** @var PaymentMethod|null $paymentMethod */ - $paymentMethod = $this->paymentMethodRepository->findOneBy(['gatewayConfig' => $gateway]); - if (null === $paymentMethod || false === $paymentMethod->isEnabled()) { + if (!$channel instanceof ChannelInterface) { return false; } - $currentChannel = $this->channelContext->getChannel(); - if (!$paymentMethod->getChannels()->contains($currentChannel)) { + $paymentMethod = $this->paymentMethodRepository->findOneEnabledByGatewayNameAndChannel( + OneyGatewayFactory::FACTORY_NAME, + $channel, + ); + + if (!$paymentMethod instanceof PaymentMethodInterface) { return false; } diff --git a/src/Twig/PayPlugExtension.php b/src/Twig/PayPlugExtension.php index 9ebfcfc7..446d75b6 100644 --- a/src/Twig/PayPlugExtension.php +++ b/src/Twig/PayPlugExtension.php @@ -4,8 +4,9 @@ namespace PayPlug\SyliusPayPlugPlugin\Twig; -use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory; +use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\Checker\CanSaveCardCheckerInterface; +use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use Sylius\Component\Core\Model\PaymentMethodInterface; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -14,7 +15,8 @@ final class PayPlugExtension extends AbstractExtension { public function __construct( private CanSaveCardCheckerInterface $canSaveCardChecker, - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, + private string $hostedFieldsSdkUrl, ) { } @@ -23,9 +25,25 @@ public function getFunctions(): array return [ new TwigFunction('is_save_card_enabled', $this->isSaveCardAllowed(...)), new TwigFunction('is_payplug_test_mode_enabled', $this->isTest(...)), + new TwigFunction('payplug_hosted_fields_company_id', $this->hostedFieldsCompanyId(...)), + new TwigFunction('payplug_hosted_fields_sdk_url', $this->hostedFieldsSdkUrl(...)), + new TwigFunction('payplug_display_mode', $this->displayMode(...)), ]; } + public function hostedFieldsSdkUrl(): string + { + return $this->hostedFieldsSdkUrl; + } + + /** + * @param array $config + */ + public function displayMode(array $config): ?string + { + return PayPlugGatewayFactory::resolveDisplayMode($config); + } + public function isSaveCardAllowed(PaymentMethodInterface $paymentMethod): bool { return $this->canSaveCardChecker->isAllowed($paymentMethod); @@ -37,4 +55,12 @@ public function isTest(PaymentMethodInterface $paymentMethod): bool return !(bool) $client->getAccount()['is_live']; } + + public function hostedFieldsCompanyId(PaymentMethodInterface $paymentMethod): string + { + $client = $this->apiClientFactory->createForPaymentMethod($paymentMethod); + $companyId = $client->getAccount()['company_ref'] ?? ''; + + return \is_string($companyId) ? $companyId : ''; + } } diff --git a/src/Upc/AuthorizationDetails.php b/src/Upc/AuthorizationDetails.php new file mode 100644 index 00000000..332c2ab4 --- /dev/null +++ b/src/Upc/AuthorizationDetails.php @@ -0,0 +1,264 @@ + true (its synchronous call was accepted but its webhook later reported it did not + * go through) no longer counts toward the totals, mirroring RefundPaymentProcessor's handling of + * a failed refund. + */ +final class AuthorizationDetails +{ + public const DEFERRED = 'hosted_fields_deferred_capture'; + + public const AUTHORIZED_AMOUNT = 'hosted_fields_authorized_amount'; + + public const MAX_CAPTURE_DATE = 'hosted_fields_max_capture_date'; + + public const CAPTURES = 'hosted_fields_captures'; + + public const CANCELLATIONS = 'hosted_fields_cancellations'; + + public const OPERATION_CAPTURE = 'capture'; + + public const OPERATION_CANCELLATION = 'cancellation'; + + /** + * How long before maxCaptureDate the admin starts warning the merchant that the + * authorization is about to lapse. + */ + private const EXPIRY_WARNING_INTERVAL = 'PT48H'; + + /** + * @param mixed[] $details + */ + private function __construct(private array $details) + { + } + + /** + * @param mixed[] $details + */ + public static function fromDetails(array $details): self + { + return new self($details); + } + + /** + * The details a freshly created authorization-only payment starts with. $fallbackAmount (the + * Sylius payment's own amount) only stands in when the creation response carried no amount + * at all — e.g. a 3DS-pending response, whose authorization is not granted yet. + * + * @param mixed[] $details + * + * @return mixed[] + */ + public static function open(array $details, PaymentOutput $output, int $fallbackAmount): array + { + return [ + ...$details, + self::DEFERRED => true, + self::AUTHORIZED_AMOUNT => $output->remainingCapturableAmount ?? $fallbackAmount, + self::MAX_CAPTURE_DATE => $output->maxCaptureDate, + self::CAPTURES => [], + self::CANCELLATIONS => [], + ]; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + public static function withOperation(array $details, string $operation, ?string $operationId, int $amount): array + { + $key = self::keyFor($operation); + $entries = \is_array($details[$key] ?? null) ? $details[$key] : []; + $entries[] = ['id' => $operationId, 'amount' => $amount]; + + return [...$details, $key => $entries]; + } + + /** + * @param mixed[] $details + * + * @return mixed[] + */ + public static function withOperationFailed(array $details, string $operation, string $operationId): array + { + $key = self::keyFor($operation); + $entries = \is_array($details[$key] ?? null) ? $details[$key] : []; + foreach ($entries as $index => $entry) { + if (\is_array($entry) && ($entry['id'] ?? null) === $operationId) { + $entries[$index] = [...$entry, 'failed' => true]; + } + } + + return [...$details, $key => $entries]; + } + + public function isDeferred(): bool + { + return true === ($this->details[self::DEFERRED] ?? false); + } + + /** + * ExecCodeMapper reads every successful execCode as PAID; for the authorization-only + * creation of a deferred payment, that success means "authorized", never "paid" — no money + * has moved yet. + */ + public function resolveCreationOutcome(string $outcome): string + { + return $this->isDeferred() && PaymentOutcome::PAID === $outcome ? PaymentOutcome::AUTHORIZED : $outcome; + } + + /** + * @return array{operation: string, amount: int}|null which of this payment's own recorded + * captures/cancellations $operationId belongs to, if any + */ + public function findOperation(string $operationId): ?array + { + foreach ([self::OPERATION_CAPTURE, self::OPERATION_CANCELLATION] as $operation) { + foreach ($this->entries($operation, includeFailed: true) as $entry) { + if ($entry['id'] === $operationId) { + return ['operation' => $operation, 'amount' => $entry['amount']]; + } + } + } + + return null; + } + + public function authorizedAmount(): int + { + $amount = $this->details[self::AUTHORIZED_AMOUNT] ?? 0; + + return \is_int($amount) ? $amount : 0; + } + + public function capturedAmount(): int + { + return self::sum($this->entries(self::OPERATION_CAPTURE)); + } + + public function cancelledAmount(): int + { + return self::sum($this->entries(self::OPERATION_CANCELLATION)); + } + + public function remainingAmount(): int + { + return \max(0, $this->authorizedAmount() - $this->capturedAmount() - $this->cancelledAmount()); + } + + public function hasCaptures(): bool + { + return [] !== $this->entries(self::OPERATION_CAPTURE); + } + + public function maxCaptureDate(): ?\DateTimeImmutable + { + $value = $this->details[self::MAX_CAPTURE_DATE] ?? null; + if (!\is_string($value) || '' === $value) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + return null; + } + } + + /** + * Unknown deadline is never "expired": the Unified API stays the authority and refuses a + * capture on a lapsed authorization itself (AuthorizationExpiredException). + */ + public function isExpired(\DateTimeImmutable $now): bool + { + $maxCaptureDate = $this->maxCaptureDate(); + + return null !== $maxCaptureDate && $now >= $maxCaptureDate; + } + + public function isExpiringSoon(\DateTimeImmutable $now): bool + { + $maxCaptureDate = $this->maxCaptureDate(); + + return null !== $maxCaptureDate && + !$this->isExpired($now) && + $now->add(new \DateInterval(self::EXPIRY_WARNING_INTERVAL)) >= $maxCaptureDate; + } + + /** + * Changes with every capture/cancellation recorded, failed or not. Carried by the admin forms + * and checked again under the lock, so a replayed or double-submitted form — built against a + * state that has since moved on — is refused rather than triggering a second real operation. + */ + public function version(): int + { + return \count($this->entries(self::OPERATION_CAPTURE, includeFailed: true)) + + \count($this->entries(self::OPERATION_CANCELLATION, includeFailed: true)); + } + + private static function keyFor(string $operation): string + { + return match ($operation) { + self::OPERATION_CAPTURE => self::CAPTURES, + self::OPERATION_CANCELLATION => self::CANCELLATIONS, + default => throw new \InvalidArgumentException(\sprintf('Unknown authorization operation "%s".', $operation)), + }; + } + + /** + * @return list + */ + private function entries(string $operation, bool $includeFailed = false): array + { + $raw = $this->details[self::keyFor($operation)] ?? []; + if (!\is_array($raw)) { + return []; + } + + $entries = []; + foreach ($raw as $entry) { + if (!\is_array($entry) || !\is_int($entry['amount'] ?? null)) { + continue; + } + if (!$includeFailed && true === ($entry['failed'] ?? false)) { + continue; + } + + $id = $entry['id'] ?? null; + $entries[] = ['id' => \is_string($id) ? $id : null, 'amount' => $entry['amount']]; + } + + return $entries; + } + + /** + * @param list $entries + */ + private static function sum(array $entries): int + { + return \array_sum(\array_column($entries, 'amount')); + } +} diff --git a/src/Upc/AuthorizationLockKey.php b/src/Upc/AuthorizationLockKey.php new file mode 100644 index 00000000..76eeb8ab --- /dev/null +++ b/src/Upc/AuthorizationLockKey.php @@ -0,0 +1,29 @@ + $maxCaptureDate]; + } +} diff --git a/src/Upc/CardDataFromPaymentMethodExtractor.php b/src/Upc/CardDataFromPaymentMethodExtractor.php new file mode 100644 index 00000000..7269e568 --- /dev/null +++ b/src/Upc/CardDataFromPaymentMethodExtractor.php @@ -0,0 +1,122 @@ + $aliasId]; + } + } + + return []; + } + + /** @return array{brand?: string, last4?: string} */ + private static function extractFromCard(mixed $card): array + { + if (!\is_array($card)) { + return []; + } + + $result = []; + + $network = $card['network'] ?? null; + if (\is_string($network) && '' !== $network) { + $result['brand'] = $network; + } + + $code6x4 = $card['code6x4'] ?? null; + if (\is_string($code6x4) && \strlen($code6x4) >= 4) { + $result['last4'] = \substr($code6x4, -4); + } + + return $result; + } + + /** + * @param bool $hasBrand true when a brand was already resolved from the card object, taking + * precedence over the details' own selectedBrand + * + * @return array{brand?: string, expirationMonth?: int, expirationYear?: int} + */ + private static function extractFromDetails(mixed $cardDetails, bool $hasBrand): array + { + if (!\is_array($cardDetails)) { + return []; + } + + $result = []; + + $selectedBrand = $cardDetails['selectedBrand'] ?? null; + if (!$hasBrand && \is_string($selectedBrand) && '' !== $selectedBrand) { + $result['brand'] = $selectedBrand; + } + + $validityDate = $cardDetails['validityDate'] ?? null; + if (\is_string($validityDate) && 1 === \preg_match('/^(\d{4})-(\d{2})$/', $validityDate, $matches)) { + $month = (int) $matches[2]; + if ($month >= 1 && $month <= 12) { + $result['expirationYear'] = (int) $matches[1]; + $result['expirationMonth'] = $month; + } + } + + return $result; + } +} diff --git a/src/Upc/CustomerTitleResolver.php b/src/Upc/CustomerTitleResolver.php new file mode 100644 index 00000000..61d9e27b --- /dev/null +++ b/src/Upc/CustomerTitleResolver.php @@ -0,0 +1,30 @@ + 'MR', + 'f' => 'MRS', + default => null, + }; + } +} diff --git a/src/Upc/GatewayCredentialsResolver.php b/src/Upc/GatewayCredentialsResolver.php new file mode 100644 index 00000000..6650b8d5 --- /dev/null +++ b/src/Upc/GatewayCredentialsResolver.php @@ -0,0 +1,50 @@ +getGatewayConfig()?->getConfig() ?? []; + $accountId = $gatewayConfig[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null; + if (!\is_string($accountId) || '' === $accountId) { + throw new \LogicException('Hosted Fields account id is not configured for this payment method.'); + } + + return $accountId; + } +} diff --git a/src/Upc/IntegrationDescriptionProvider.php b/src/Upc/IntegrationDescriptionProvider.php new file mode 100644 index 00000000..e3663bb5 --- /dev/null +++ b/src/Upc/IntegrationDescriptionProvider.php @@ -0,0 +1,62 @@ +getBillingAddress(); + if (null === $address) { + return null; + } + + return new BillingDto( + $this->buildAddress($address), + $this->buildContact($address), + $this->title($order), + ); + } + + public function createShipping(OrderInterface $order): ?ShippingDto + { + $address = $order->getShippingAddress(); + if (null === $address) { + return null; + } + + return new ShippingDto( + $this->buildAddress($address), + $this->buildContact($address), + $order->getCustomer()?->getEmail(), + $address->getCompany(), + ); + } + + private function buildContact(AddressInterface $address): ContactDto + { + [$phone, $mobilePhone] = $this->splitPhone($address); + + return new ContactDto($address->getFirstName(), $address->getLastName(), $phone, $mobilePhone); + } + + private function buildAddress(AddressInterface $address): AddressDto + { + return new AddressDto( + $address->getStreet(), + $address->getCity(), + $address->getCountryCode(), + // AddressDto::$state is documented as 0-3 chars, so a short province CODE (e.g. "75") + // fits where the full province NAME (used elsewhere in this plugin's legacy + // PayPlugPaymentDataCreator) would not — but Sylius province codes aren't always that + // short (e.g. ISO-3166-2-style "US-CA"), so only pass one through when it actually + // fits, rather than risk sending a malformed state to the Unified API. + $this->shortProvinceCode($address), + $address->getPostcode(), + ); + } + + private function shortProvinceCode(AddressInterface $address): ?string + { + $provinceCode = $address->getProvinceCode(); + + return null !== $provinceCode && \strlen($provinceCode) <= 3 ? $provinceCode : null; + } + + /** + * @return array{0: string|null, 1: string|null} [phone, mobilePhone] — Sylius stores only one + * phone number per address, so only one of the two ever comes back non-null here, matching + * whichever type PhoneHelper::isMobile() detects it as; an unparseable/invalid number is + * treated as absent rather than failing the whole payment over supplementary contact data. + */ + private function splitPhone(AddressInterface $address): array + { + $rawPhone = $address->getPhoneNumber(); + $countryCode = $address->getCountryCode(); + if (null === $rawPhone || '' === $rawPhone || null === $countryCode) { + return [null, null]; + } + + try { + $e164Phone = PhoneHelper::toE164($rawPhone, $countryCode); + $isMobile = PhoneHelper::isMobile($rawPhone, $countryCode); + } catch (InvalidPhoneNumberException) { + return [null, null]; + } + + return $isMobile ? [null, $e164Phone] : [$e164Phone, null]; + } + + private function title(OrderInterface $order): ?string + { + $gender = $order->getCustomer()?->getGender(); + + return null !== $gender ? CustomerTitleResolver::resolve($gender) : null; + } +} diff --git a/src/Upc/PaymentCaptureContextBuilder.php b/src/Upc/PaymentCaptureContextBuilder.php new file mode 100644 index 00000000..cedea723 --- /dev/null +++ b/src/Upc/PaymentCaptureContextBuilder.php @@ -0,0 +1,183 @@ +getPayment()->getId()); + // No submerchantExternalId: only the EUR MID configurations carry one, and Hosted Fields + // here targets the multi-currency ones. UPC omits the key entirely rather than sending null. + $common = new CommonFieldsDto($accountId, $amount, \strtoupper($currencyCode), $orderId); + $common->description = $this->resolveDescription($order); + // Deferred capture: open an authorization only, captured/cancelled later from the admin + // order screen (see AuthorizedPaymentOperationProcessor). + $common->capture = !PayPlugGatewayFactory::isDeferredCaptureHostedFieldsConfig( + $paymentRequest->getPayment()->getMethod()?->getGatewayConfig(), + ); + // Confirmed with PayPlug: this field has no effect on their side regardless of value for + // Hosted Fields/UPC — the only working notification path is the static Cockpit-configured + // Receiver at /payplug/v2/ipn (see UnifiedApiIpnAction's docblock). Set anyway to keep the + // DTO's contract intact rather than leaving the field unset. + $common->notificationUrl = $this->urlGenerator->generate( + 'sylius_payment_request_notify', + ['hash' => (string) $paymentRequest->getHash()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + $successUrl = $this->afterPayUrlProvider->getUrl($paymentRequest, UrlGeneratorInterface::ABSOLUTE_URL); + $common->successUrl = $successUrl; + $common->cancelUrl = $successUrl . '?' . http_build_query(['status' => 'canceled']); + if (null !== $order) { + $common->billing = $this->orderAddressDtoCreator->createBilling($order); + $common->shipping = $this->orderAddressDtoCreator->createShipping($order); + } + + return $common; + } + + public function buildBrowserDto(): ?BrowserDto + { + $request = $this->requestStack->getCurrentRequest(); + + return null !== $request + ? new BrowserDto( + $request->getClientIp() ?? '', + $request->headers->get('referer', '') ?? '', + $request->headers->get('User-Agent', '') ?? '', + ) + : null; + } + + public function resolvePaymentMethod(PaymentInterface $payment): PaymentMethodInterface + { + $method = $payment->getMethod(); + if (null === $method) { + throw new \LogicException('Payment method is not set for the payment.'); + } + + return $method; + } + + /** @return array{0: int, 1: string} */ + public function resolveAmountAndCurrency(PaymentInterface $payment): array + { + $amount = $payment->getAmount(); + $currencyCode = $payment->getCurrencyCode(); + if (null === $amount || null === $currencyCode) { + throw new \LogicException('Payment amount or currency is not set.'); + } + + return [$amount, $currencyCode]; + } + + /** + * Extracted from a payment-creation response body rather than PaymentOutput itself (which + * carries no such fields) — needed by StatusHostedPaymentRequestHandler's 3DS polling + * fallback and by UnifiedApiIpnAction::resolveHostedFieldsPayment(), which looks these ids up + * on Payment::details. Shared by both capture handlers (token and alias) since either flow's + * response can carry a pending 3DS challenge that only the webhook/polling path resolves. + * + * @return array{hosted_fields_payment_id?: string, hosted_fields_operation_id?: string} + */ + public function resolveHostedFieldsIds(string $body): array + { + $decoded = \json_decode($body, true); + $paymentId = \is_array($decoded) ? ($decoded['id'] ?? null) : null; + $operationIds = \is_array($decoded) ? ($decoded['operationIds'] ?? null) : null; + $operationId = \is_array($operationIds) ? ($operationIds[0] ?? null) : null; + + // Each id is stored independently — a response carrying only one of the two (e.g. no + // operationIds yet) must not also drop the other, since dropping hosted_fields_operation_id + // silently disables the card-metadata enrichment in PayplugCardPersister::persist(). + $result = []; + if (\is_string($paymentId) && '' !== $paymentId) { + $result['hosted_fields_payment_id'] = $paymentId; + } + if (\is_string($operationId) && '' !== $operationId) { + $result['hosted_fields_operation_id'] = $operationId; + } + + return $result; + } + + public function buildCustomerDto(?OrderInterface $order): CustomerDto + { + $customer = $order?->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + throw new \LogicException('Customer email is not set for the payment.'); + } + + return new CustomerDto(ResourceIdentifier::toString($customer->getId()), $customer->getEmail()); + } + + /** + * Falls back to the billing address's own full name, then the customer's, so a + * paymentMethod.details.fullName the Unified API requires whenever saveFutureUsage is + * requested isn't left unset just because the billing address itself has none set. + */ + public function resolveFullNameForCardDetails(?OrderInterface $order): ?string + { + $fullName = $order?->getBillingAddress()?->getFullName(); + if (null !== $fullName && '' !== $fullName) { + return $fullName; + } + + $fullName = $order?->getCustomer()?->getFullName(); + + return null !== $fullName && '' !== $fullName ? $fullName : null; + } + + // Identifies the order's own product for PayPlug's back office; falls back to a generic + // integration/version string only when there's no order or no line item to name (e.g. this + // is also used by the pay-with-an-existing-alias flow, which shares the same order shape). + private function resolveDescription(?OrderInterface $order): ?string + { + $firstItemOrFalse = $order?->getItems()->first(); + $firstItem = false !== $firstItemOrFalse ? $firstItemOrFalse : null; + + return null !== $firstItem ? $firstItem->getProductName() : IntegrationDescriptionProvider::build(); + } +} diff --git a/src/Upc/PaymentCaptureOutcomeApplier.php b/src/Upc/PaymentCaptureOutcomeApplier.php new file mode 100644 index 00000000..d1825d45 --- /dev/null +++ b/src/Upc/PaymentCaptureOutcomeApplier.php @@ -0,0 +1,145 @@ +logger->error(\sprintf('[PayPlug][UPC] %s payment creation failed.', $flow->value), [ + 'sylius_payment_id' => $payment->getId(), + 'error' => $e->getMessage(), + ]); + $paymentRequest->setResponseData(['error' => $e->getMessage()]); + $this->notifyShopper(); + $this->stateMachine->apply($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + } + + /** + * Without this the capture failure is entirely silent to the customer: the Payment stays + * "new" and the order "awaiting_payment" (both intentional — the payment is still + * retryable), so they are simply redirected to the order summary with no indication that + * anything went wrong. + * + * A missing session is not an error to report: this runs from the CLI + * (UpdatePaymentStateCommand) and from worker contexts too, where Request::getSession() + * throws. There is no shopper to tell in those cases, and a failed payment must not turn + * into a 500 because there was nowhere to put the message. + */ + private function notifyShopper(): void + { + $request = $this->requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + return; + } + + $session = $request->getSession(); + if (!$session instanceof FlashBagAwareSessionInterface) { + return; + } + + $session->getFlashBag()->add('error', self::SHOPPER_ERROR_FLASH_KEY); + } + + /** + * Refunds are keyed by the payment's top-level id, so a creation response without one leaves + * the payment unrefundable from the back office. Ids only: the response body carries customer + * data. + * + * @param array{hosted_fields_payment_id?: string, hosted_fields_operation_id?: string} $hostedFieldsIds + */ + public function warnWhenPaymentIdMissing( + PaymentInterface $payment, + PaymentCaptureFlow $flow, + array $hostedFieldsIds, + ): void { + if (isset($hostedFieldsIds['hosted_fields_payment_id'])) { + return; + } + + $this->logger->warning('[PayPlug][UPC] Payment created but the response carried no payment id; it cannot be refunded from the back office until PayPlug returns a payment id.', [ + 'sylius_payment_id' => $payment->getId(), + 'flow' => $flow->value, + 'hosted_fields_operation_id' => $hostedFieldsIds['hosted_fields_operation_id'] ?? null, + ]); + } + + public function applyOutcome( + PaymentRequestInterface $paymentRequest, + PaymentInterface $payment, + PaymentOutput $output, + ): void { + if (null !== $output->redirectHtml) { + // The "recommended for web" 3DS-pending shape — an auto-submitting HTML form the + // browser must render directly (see CaptureHttpResponseProvider). This is what the + // Unified API actually returns by default; redirectUrl only appears when the request + // explicitly set card.threeDSecure.displayMode=raw, which this plugin never does. + $paymentRequest->setResponseData(['redirect_html' => $output->redirectHtml]); + + return; + } + + if (null !== $output->redirectUrl) { + $paymentRequest->setResponseData(['redirect_url' => $output->redirectUrl]); + + return; + } + + $paymentRequest->setResponseData(['status' => $output->status]); + + // No 3DS redirect means the outcome is already known synchronously — apply it to the + // actual Sylius Payment right away instead of waiting on the async webhook, which may + // be delayed or, in this test environment, never arrive at all. SyliusOrderStateMutator + // is idempotent (checks the state machine before transitioning), so it's safe to also + // run again if/when the webhook (e.g. NotifyHostedPaymentRequestHandler) eventually shows up. + $responseBody = \json_decode($output->body, true); + $execCode = \is_array($responseBody) ? ($responseBody['execCode'] ?? null) : null; + if (\is_string($execCode)) { + // An authorization-only payment's success is "authorized", not "paid" — see + // AuthorizationDetails::resolveCreationOutcome(). + $outcome = AuthorizationDetails::fromDetails($payment->getDetails())->resolveCreationOutcome(ExecCodeMapper::toPaymentOutcome($execCode)); + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), $outcome); + } + } +} diff --git a/src/Upc/PaymentOrderIdResolver.php b/src/Upc/PaymentOrderIdResolver.php new file mode 100644 index 00000000..12252001 --- /dev/null +++ b/src/Upc/PaymentOrderIdResolver.php @@ -0,0 +1,29 @@ +getNumber() ?? ResourceIdentifier::toString($paymentId); + } +} diff --git a/src/Upc/PayplugCardPersister.php b/src/Upc/PayplugCardPersister.php new file mode 100644 index 00000000..58fb90bb --- /dev/null +++ b/src/Upc/PayplugCardPersister.php @@ -0,0 +1,149 @@ +getOrder(); + + $customer = $order?->getCustomer(); + if (null === $customer) { + return; + } + + // Payment::getMethod() is only typed to the base Payment component's PaymentMethodInterface, + // but Card::$paymentMethod is a mandatory (non-nullable) association requiring Sylius + // Core's narrower PaymentMethodInterface (the only kind Sylius actually wires up at + // runtime) — bail out before building a Card at all rather than persisting one with that + // required field left unset, which would only fail later at flush time. + if (!$method instanceof CorePaymentMethodInterface) { + return; + } + + $gatewayConfig = $method->getGatewayConfig()?->getConfig() ?? []; + $isLive = true === ($gatewayConfig['live'] ?? false); + + // Guards against double-saving the same alias — e.g. a 3DS payment whose webhook fires + // more than once for reasons outside isTreated()'s own operation-id dedupe (a different + // operation id notifying the same alias), or any future path that also calls persist() + // for an alias already stored. This check-then-act is not by itself race-proof — the + // synchronous frictionless capture and the async webhook can both reach persist() for the + // same alias — so a DB-level unique constraint on (external_id, is_live) backs it up; see + // the catch below. + if (null !== $this->payplugCardRepository->findOneBy(['externalId' => $aliasId, 'isLive' => $isLive])) { + return; + } + + // $details' own hosted_fields_* values are fully client-controlled (see + // hosted-fields_controller.js) — used only as a display-only fallback for whatever + // $fetchedCardData (PayPlug's own API/webhook data) doesn't carry, and validated here + // rather than trusted as-is: anything not matching the expected shape is discarded as if + // it were absent. + $currentYear = (int) (new \DateTimeImmutable())->format('Y'); + $brand = $fetchedCardData['brand'] ?? $this->sanitizeBrand($details['hosted_fields_selected_brand'] ?? null); + $last4 = $fetchedCardData['last4'] + ?? $this->sanitizeString($details['hosted_fields_last4'] ?? null, self::LAST4_PATTERN); + $expirationMonth = $fetchedCardData['expirationMonth'] + ?? $this->sanitizeIntInRange($details['hosted_fields_expiration_month'] ?? null, 1, 12); + $expirationYear = $fetchedCardData['expirationYear'] + ?? $this->sanitizeIntInRange( + $details['hosted_fields_expiration_year'] ?? null, + $currentYear, + $currentYear + self::MAX_EXPIRATION_YEARS_AHEAD, + ); + // No card country field exists on the operation resource (confirmed against a real + // staging response) — $countryCode keeps relying entirely on the client-submitted + // $details value. + $countryCodeCandidate = $this->sanitizeString($details['hosted_fields_country'] ?? null, self::COUNTRY_CODE_PATTERN); + $countryCode = null !== $countryCodeCandidate ? \strtoupper($countryCodeCandidate) : null; + + /** @var Card $card */ + $card = $this->payplugCardFactory->createNew(); + $card + ->setCustomer($customer) + ->setExternalId($aliasId) + ->setBrand(\is_string($brand) ? $brand : '') + ->setLast4(\is_string($last4) ? $last4 : '') + ->setExpirationMonth(\is_int($expirationMonth) ? $expirationMonth : 0) + ->setExpirationYear(\is_int($expirationYear) ? $expirationYear : 0) + ->setCountryCode(\is_string($countryCode) ? $countryCode : '') + ->setIsLive($isLive) + ->setPaymentMethod($method) + ; + + try { + $this->payplugCardRepository->add($card); + } catch (UniqueConstraintViolationException) { + // The findOneBy() guard above lost a race against a concurrent persist() call for the + // same alias — that other call already stored the canonical Card row, so there is + // nothing left to do here. Doctrine's UnitOfWork closes the EntityManager on ANY + // flush failure, catch included — reset the registry so Doctrine work resolved fresh + // after this point doesn't inherit the now-closed instance. + $this->managerRegistry->resetManager(); + } + } + + private function sanitizeBrand(mixed $value): ?string + { + return \is_string($value) && \in_array($value, self::ALLOWED_BRANDS, true) ? $value : null; + } + + private function sanitizeString(mixed $value, string $pattern): ?string + { + return \is_string($value) && 1 === \preg_match($pattern, $value) ? $value : null; + } + + private function sanitizeIntInRange(mixed $value, int $min, int $max): ?int + { + return \is_int($value) && $value >= $min && $value <= $max ? $value : null; + } +} diff --git a/src/Upc/RefundCreatorInterface.php b/src/Upc/RefundCreatorInterface.php new file mode 100644 index 00000000..3e84bff4 --- /dev/null +++ b/src/Upc/RefundCreatorInterface.php @@ -0,0 +1,35 @@ +paymentRepository->find((int) $orderId); + if (null === $payment) { + $this->logger->error('[PayPlug][UPC] Cannot apply payment outcome: payment not found.', [ + 'sylius_payment_id' => $orderId, + 'outcome' => $outcome, + ]); + + return; + } + + $transition = match ($outcome) { + PaymentOutcome::PAID, PaymentOutcome::CAPTURE_REQUIRED => PaymentTransitions::TRANSITION_COMPLETE, + PaymentOutcome::AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, + PaymentOutcome::REFUNDED => PaymentTransitions::TRANSITION_REFUND, + PaymentOutcome::FAILED => PaymentTransitions::TRANSITION_FAIL, + // THREE_DS_PENDING is not applied as a state transition here: it's not a final + // outcome, so there is nothing to transition to yet — the payment simply stays + // "processing" until a real outcome arrives. The three async notification call sites + // (HostedFieldsWebhookNotificationHandler::treat(), NotifyHostedPaymentRequestHandler, + // and StatusHostedPaymentRequestHandler via delegation to treat()) already guard + // against THREE_DS_PENDING before ever reaching this mutator; this arm is the + // defensive backstop for the one caller that doesn't (CaptureHostedPaymentRequestHandler's + // synchronous branch, which passes ExecCodeMapper::toPaymentOutcome() straight through). + default => null, + }; + + if (null === $transition) { + return; + } + + if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->logger->warning('[PayPlug][UPC] Cannot apply payment transition (already applied or incompatible with current state).', [ + 'sylius_payment_id' => $orderId, + 'current_state' => $payment->getState(), + 'transition' => $transition, + 'outcome' => $outcome, + ]); + + return; + } + + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + } +} diff --git a/src/Upc/SyliusPaymentOperationRepository.php b/src/Upc/SyliusPaymentOperationRepository.php new file mode 100644 index 00000000..58b9b13c --- /dev/null +++ b/src/Upc/SyliusPaymentOperationRepository.php @@ -0,0 +1,86 @@ +findOneBy(['orderId' => $orderId]); + if (null === $entity) { + throw new PaymentNotFoundException(\sprintf('No operation for order "%s".', $orderId)); + } + + return $entity->toOperationData(); + } + + public function getByOperationId(string $operationId): OperationData + { + $entity = $this->findOneByOperationId($operationId); + if (null === $entity) { + throw new PaymentNotFoundException(\sprintf('No operation "%s".', $operationId)); + } + + return $entity->toOperationData(); + } + + public function save(OperationData $operationData): void + { + $entity = $this->findOneByOperationId($operationData->operationId); + if (null === $entity) { + $entity = new PayPlugOperation( + $operationData->orderId, + $operationData->operationId, + $operationData->execCode, + $operationData->outcome, + $operationData->amount, + ); + $this->entityManager->persist($entity); + } + + $this->entityManager->flush(); + } + + public function markTreated(string $operationId): void + { + $entity = $this->findOneByOperationId($operationId); + if (null === $entity) { + throw new PaymentNotFoundException(\sprintf('No operation "%s".', $operationId)); + } + + $entity->markTreated(); + $this->entityManager->flush(); + } + + public function isTreated(string $operationId): bool + { + $entity = $this->findOneByOperationId($operationId); + + return null !== $entity && $entity->isTreated(); + } + + /** + * @param array $criteria + */ + private function findOneBy(array $criteria): ?PayPlugOperation + { + return $this->entityManager->getRepository(PayPlugOperation::class)->findOneBy($criteria); + } + + private function findOneByOperationId(string $operationId): ?PayPlugOperation + { + return $this->findOneBy(['operationId' => $operationId]); + } +} diff --git a/src/Upc/SyliusUnifiedApiHttpClient.php b/src/Upc/SyliusUnifiedApiHttpClient.php new file mode 100644 index 00000000..a15a82bd --- /dev/null +++ b/src/Upc/SyliusUnifiedApiHttpClient.php @@ -0,0 +1,82 @@ +send('GET', $url, ['headers' => $headers]); + } + + public function postJson(string $url, array $body, array $headers = []): array + { + return $this->send('POST', $url, ['json' => $body, 'headers' => $headers]); + } + + /** + * @param array $options + * + * @return array{status: int, body: string} + */ + private function send(string $method, string $url, array $options): array + { + // Off only when payplug.unified_api_verify_tls is explicitly disabled for a QA/staging + // host with an untrusted internal CA (see config/services.yaml) — never in production. + if (!$this->unifiedApiVerifyTls) { + $options['verify_peer'] = false; + $options['verify_host'] = false; + } + + $options['timeout'] = self::REQUEST_TIMEOUT_SECONDS; + + $this->logger->debug('[PayPlug debug] Unified API raw request.', [ + 'method' => $method, + 'url' => $url, + 'headers' => $options['headers'] ?? [], + 'json' => $options['json'] ?? null, + ]); + + try { + $response = $this->httpClient->request($method, $url, $options); + $status = $response->getStatusCode(); + $body = $response->getContent(false); + + $this->logger->debug('[PayPlug debug] Unified API raw response.', [ + 'status' => $status, + 'body' => $body, + ]); + + return [ + 'status' => $status, + 'body' => $body, + ]; + } catch (TransportExceptionInterface $e) { + $this->logger->debug('[PayPlug debug] Unified API transport exception.', [ + 'message' => $e->getMessage(), + ]); + + return ['status' => 0, 'body' => $e->getMessage()]; + } + } +} diff --git a/src/Upc/SyliusUpcConfigurationRepository.php b/src/Upc/SyliusUpcConfigurationRepository.php new file mode 100644 index 00000000..24c6d8be --- /dev/null +++ b/src/Upc/SyliusUpcConfigurationRepository.php @@ -0,0 +1,111 @@ +gatewayConfig = $gatewayConfig; + + return $scoped; + } + + public function forPaymentMethod(PaymentMethodInterface $paymentMethod): ScopedConfigurationRepositoryInterface + { + return $this->withGatewayConfig( + $paymentMethod->getGatewayConfig() + ?? throw new \LogicException('The payment method has no gateway config, so no PayPlug account can be resolved for it.'), + ); + } + + public function get(string $key): ?string + { + $value = $this->findGatewayConfig()->getConfig()[$key] ?? null; + + return \is_string($value) ? $value : null; + } + + public function set(string $key, string $value): void + { + $gatewayConfig = $this->findGatewayConfig(); + $gatewayConfig->setConfig([...$gatewayConfig->getConfig(), $key => $value]); + $this->entityManager->flush(); + } + + public function getClientId(): string + { + $value = $this->getClientConfig()['client_id'] ?? ''; + + return \is_string($value) ? $value : ''; + } + + public function getClientSecret(): string + { + $value = $this->getClientConfig()['client_secret'] ?? ''; + + return \is_string($value) ? $value : ''; + } + + public function getPublicKeyId(): string + { + $value = $this->findGatewayConfig()->getConfig()[PayPlugGatewayFactory::HF_IDENTIFIER] ?? ''; + + return \is_string($value) ? $value : ''; + } + + /** + * Not populated by any current admin field. Returns '' until a future ticket adds a + * distinct public-key-value config field. + */ + public function getPublicKeyValue(): string + { + return ''; + } + + /** + * @return array + */ + private function getClientConfig(): array + { + $config = $this->findGatewayConfig()->getConfig(); + $isLive = true === ($config['live'] ?? false); + $rawClientConfig = $isLive ? ($config['live_client'] ?? null) : ($config['test_client'] ?? null); + + if (!\is_array($rawClientConfig)) { + return []; + } + + return $rawClientConfig; + } + + /** + * Deliberately no fallback to findOneBy(['factoryName' => …]). That lookup was correct only + * while exactly one CB gateway config could exist; since PRE-3628 it returns an arbitrary one + * of several, which would sign a channel's payment with another channel's account. An + * unscoped call is a bug at the call site, so it fails loudly here rather than silently + * resolving the wrong merchant. + */ + private function findGatewayConfig(): GatewayConfigInterface + { + return $this->gatewayConfig ?? throw new \LogicException( + 'The UPC configuration repository has not been scoped to a gateway config. ' . + 'Call withGatewayConfig() with the gateway config of the payment method being handled.', + ); + } +} diff --git a/src/Upc/SyliusUpcLock.php b/src/Upc/SyliusUpcLock.php new file mode 100644 index 00000000..5e801dea --- /dev/null +++ b/src/Upc/SyliusUpcLock.php @@ -0,0 +1,39 @@ + */ + private array $locks = []; + + public function __construct(private LockFactory $lockFactory) + { + } + + public function acquire(string $key, int $ttlSeconds): bool + { + $lock = $this->lockFactory->createLock($key, $ttlSeconds); + if (!$lock->acquire(false)) { + return false; + } + + $this->locks[$key] = $lock; + + return true; + } + + public function release(string $key): void + { + if (isset($this->locks[$key])) { + $this->locks[$key]->release(); + unset($this->locks[$key]); + } + } +} diff --git a/src/Upc/SyliusUpcLogger.php b/src/Upc/SyliusUpcLogger.php new file mode 100644 index 00000000..b64ccab1 --- /dev/null +++ b/src/Upc/SyliusUpcLogger.php @@ -0,0 +1,30 @@ +psrLogger->debug($message, $context); + } + + public function info(string $message, array $context = []): void + { + $this->psrLogger->info($message, $context); + } + + public function error(string $message, array $context = []): void + { + $this->psrLogger->error($message, $context); + } +} diff --git a/src/Upc/UnifiedApiAuthorizationOperator.php b/src/Upc/UnifiedApiAuthorizationOperator.php new file mode 100644 index 00000000..8a2bc739 --- /dev/null +++ b/src/Upc/UnifiedApiAuthorizationOperator.php @@ -0,0 +1,74 @@ +createService($method)->capturePayment( + $paymentId, + GatewayCredentialsResolver::resolve($method), + $orderId, + \sprintf('Capture #%d for order %s', $sequence, $orderId), + $amount, + null, + $currency, + ); + } + + public function cancel( + PaymentMethodInterface $method, + string $paymentId, + string $orderId, + ?int $amount, + ?string $currency = null, + int $sequence = 1, + ): CancellationOutput { + return $this->createService($method)->cancelPayment( + $paymentId, + GatewayCredentialsResolver::resolve($method), + $orderId, + \sprintf('Cancellation #%d for order %s', $sequence, $orderId), + $amount, + null, + $currency, + ); + } + + private function createService(PaymentMethodInterface $method): UnifiedApiPaymentService + { + $configuration = $this->configurationRepository->forPaymentMethod($method); + + return new UnifiedApiPaymentService( + $this->httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $configuration->getClientId(), + $configuration->getClientSecret(), + ); + } +} diff --git a/src/Upc/UnifiedApiOperationStatusFetcher.php b/src/Upc/UnifiedApiOperationStatusFetcher.php new file mode 100644 index 00000000..38994784 --- /dev/null +++ b/src/Upc/UnifiedApiOperationStatusFetcher.php @@ -0,0 +1,36 @@ +configurationRepository->forPaymentMethod($method); + + $service = new UnifiedApiPaymentService( + $this->httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $configuration->getClientId(), + $configuration->getClientSecret(), + ); + + return $service->getOperation($operationId); + } +} diff --git a/src/Upc/UnifiedApiPaymentCreator.php b/src/Upc/UnifiedApiPaymentCreator.php new file mode 100644 index 00000000..779714ad --- /dev/null +++ b/src/Upc/UnifiedApiPaymentCreator.php @@ -0,0 +1,38 @@ +configurationRepository->forPaymentMethod($method); + + $service = new UnifiedApiPaymentService( + $this->httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $configuration->getClientId(), + $configuration->getClientSecret(), + ); + + return $service->createPayment($dto); + } +} diff --git a/src/Upc/UnifiedApiPaymentCreatorInterface.php b/src/Upc/UnifiedApiPaymentCreatorInterface.php new file mode 100644 index 00000000..e33cd35d --- /dev/null +++ b/src/Upc/UnifiedApiPaymentCreatorInterface.php @@ -0,0 +1,22 @@ +configurationRepository->forPaymentMethod($method); + + $service = new UnifiedApiPaymentService( + $this->httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $configuration->getClientId(), + $configuration->getClientSecret(), + ); + + return $service->createRefund( + $operationId, + $accountId, + $orderId, + \sprintf('Refund for order %s', $orderId), + null, + $amount, + $currency, + ); + } +} diff --git a/src/Validator/PaymentMethodValidator.php b/src/Validator/PaymentMethodValidator.php index 1ce571aa..26aa539f 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -12,8 +12,10 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsOneyEnabled; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use Sylius\Component\Core\Model\PaymentMethodInterface; @@ -45,11 +47,11 @@ public function process(PaymentMethodInterface $paymentMethod): void $errors = match ($paymentMethod->getGatewayConfig()->getFactoryName()) { PayPlugGatewayFactory::FACTORY_NAME => $this->processPayplug($paymentMethod), OneyGatewayFactory::FACTORY_NAME => $this->processOney($paymentMethod), - BancontactGatewayFactory::FACTORY_NAME => $this->processBancontact($paymentMethod), - AmericanExpressGatewayFactory::FACTORY_NAME => $this->processAmex($paymentMethod), - ApplePayGatewayFactory::FACTORY_NAME => $this->processApplePay($paymentMethod), + BancontactGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + AmericanExpressGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), + ApplePayGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), ScalapayGatewayFactory::FACTORY_NAME => $this->processScalapay($paymentMethod), - WeroGatewayFactory::FACTORY_NAME => $this->processWero($paymentMethod), + WeroGatewayFactory::FACTORY_NAME => $this->processDefault($paymentMethod), default => throw new \InvalidArgumentException('Unsupported payment method'), }; @@ -68,57 +70,38 @@ private function processPayplug(PaymentMethodInterface $paymentMethod): Constrai $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; $constraintList = [new IsCanSavePaymentMethod()]; - if (true === $config[PayPlugGatewayFactory::ONE_CLICK]) { + if (true === ($config[PayPlugGatewayFactory::ONE_CLICK] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_SAVE_CARD); } - if (true === $config[PayPlugGatewayFactory::DEFERRED_CAPTURE]) { + if (true === ($config[PayPlugGatewayFactory::DEFERRED_CAPTURE] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_CREATE_DEFERRED_PAYMENT); } - if (true === $config[PayPlugGatewayFactory::INTEGRATED_PAYMENT]) { + if (true === ($config[PayPlugGatewayFactory::INTEGRATED_PAYMENT] ?? false)) { $constraintList[] = new PayplugPermission(Permission::CAN_USE_INTEGRATED_PAYMENTS); } - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsOneyEnabled()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processBancontact(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList[] = new HasNoGatewayChannelConflict(); return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - private function processAmex(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface + private function processOney(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList = [new IsOneyEnabled(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - private function processApplePay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface + private function processDefault(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList = [new IsCanSavePaymentMethod(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } private function processScalapay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processWero(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; + $constraintList = [new IsCanSavePaymentMethod(), new IsScalapayAmountRangeValid(), new HasNoGatewayChannelConflict()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } diff --git a/templates/admin/order/show/authorization.html.twig b/templates/admin/order/show/authorization.html.twig new file mode 100644 index 00000000..1af73ed3 --- /dev/null +++ b/templates/admin/order/show/authorization.html.twig @@ -0,0 +1,110 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{# One card per deferred-capture Hosted Fields payment of the order; nothing at all for any other + payment. Which actions are offered comes from payplug_authorization() — the same rules + AuthorizedPaymentController enforces — so a form is only ever shown when it can succeed as + far as Sylius knows; the Unified API stays the final judge, and its refusal comes back as a + flash message. #} +{% set order = hookable_metadata.context.resource %} + +{% for payment in order.payments %} + {% set authorization = payplug_authorization(payment) %} + {% if authorization is not null %} + {% set currency = payment.currencyCode %} + {% set csrf_token_id = constant('PayPlug\\SyliusPayPlugPlugin\\Action\\Admin\\AuthorizedPaymentController::CSRF_TOKEN_ID_PREFIX') ~ payment.id %} + {% set csrf_token_value = sylius_csrf_protection_enabled() ? csrf_token(csrf_token_id) : null %} + {# Disables the button on the first submit, so a double click sends one request; the server + side (lock + version) is what actually guarantees it. #} + {% set single_submit = "this.querySelector('button[type=submit]').disabled = true;" %} + +
+
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.title'|trans }}
+
+
+
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.authorized_amount'|trans }}
+
{{ money.format(authorization.authorized_amount, currency) }}
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.captured_amount'|trans }}
+
{{ money.format(authorization.captured_amount, currency) }}
+ {% if authorization.cancelled_amount > 0 %} +
{{ 'payplug_sylius_payplug_plugin.admin.authorization.cancelled_amount'|trans }}
+
{{ money.format(authorization.cancelled_amount, currency) }}
+ {% endif %} +
{{ 'payplug_sylius_payplug_plugin.admin.authorization.remaining_amount'|trans }}
+
+ {{ money.format(authorization.remaining_amount, currency) }} +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.max_capture_date'|trans }}
+
+ {% if authorization.max_capture_date is not null %} + {{ authorization.max_capture_date|date('d/m/Y H:i') }} + {% else %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.max_capture_date_unknown'|trans }} + {% endif %} +
+
+ + {% if authorization.expired %} + + {% elseif authorization.expiring_soon and authorization.can_capture %} + + {% endif %} + + {% if authorization.can_capture %} +
+ + + +
+ + {{ currency }} + +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.capture_help'|trans }}
+
+ {% endif %} + + {% if authorization.can_cancel %} +
+ + + +
+ + {{ currency }} + +
+
{{ 'payplug_sylius_payplug_plugin.admin.authorization.cancel_help'|trans }}
+
+ {% endif %} + + {% if not authorization.can_capture and not authorization.expired %} +
+ {% if authorization.remaining_amount == 0 %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.nothing_remaining'|trans }} + {% else %} + {{ 'payplug_sylius_payplug_plugin.admin.authorization.not_authorized'|trans }} + {% endif %} +
+ {% endif %} +
+
+ {% endif %} +{% endfor %} diff --git a/templates/admin/order/show/payment_complete.html.twig b/templates/admin/order/show/payment_complete.html.twig new file mode 100644 index 00000000..174dbcd1 --- /dev/null +++ b/templates/admin/order/show/payment_complete.html.twig @@ -0,0 +1,7 @@ +{# Replaces Sylius's own "Complete" payment action only for a deferred-capture Hosted Fields + payment, whose capture is driven from the PayPlug authorization block instead (partial amounts, + remaining amount, deadline, explicit refusal messages). Every other payment keeps Sylius's + action untouched. #} +{% if payplug_authorization(hookable_metadata.context.payment) is null %} + {% include '@SyliusAdmin/order/show/content/sections/payments/item/actions/complete.html.twig' %} +{% endif %} diff --git a/templates/admin/payment_method/form/connected_account.html.twig b/templates/admin/payment_method/form/connected_account.html.twig new file mode 100644 index 00000000..870ba964 --- /dev/null +++ b/templates/admin/payment_method/form/connected_account.html.twig @@ -0,0 +1,47 @@ +{# Read-only: the email is written by the OAuth callback, never edited here, so this is plain text + rather than a form field. Sourced from `form.vars.data` — the same path the surrounding Sylius + section template uses to read `gatewayConfig.factoryName` — since `account_email` is stored on + the gateway config without a form child of its own. #} +{% set payment_method = hookable_metadata.context.form.vars.data %} +{% set gateway_config = payment_method.gatewayConfig %} +{% set account_email = gateway_config ? gateway_config.config['account_email']|default(null) : null %} +{# What logout would actually clear. Absent on a gateway saved but never authorized, where the + button would be a no-op that disables a payment method for nothing. #} +{% set is_connected = gateway_config and (gateway_config.config['live_client']|default(null) or gateway_config.config['test_client']|default(null)) %} + +
+
{{ 'payplug_sylius_payplug_plugin.ui.connected_account'|trans }}
+ {# Three states, not two: a gateway that was never authorized has no email *and* no credentials, + and telling that merchant to "re-authenticate" implies a connection they never had. The + "unknown" wording is for a gateway connected before PRE-3631, which has credentials but no + account_email until the merchant reconnects. #} + {% if not is_connected %} +
{{ 'payplug_sylius_payplug_plugin.ui.not_connected'|trans }}
+ {% elseif account_email %} +
{{ account_email }}
+ {% else %} +
{{ 'payplug_sylius_payplug_plugin.ui.connected_account_unknown'|trans }}
+ {% endif %} + + {# A link rather than a submit button: this block is rendered inside the payment-method form, + and a nested
would be invalid HTML. The CSRF token is therefore carried in the query + string and verified by UnifiedLogoutController — whose token-id prefix is read from the + controller itself so the two ends cannot drift apart. + + `noreferrer` keeps that query-string token out of the Referer header should this admin page + ever load a third-party resource — the one leak a POST body would not have. (`nofollow` is + inert behind admin authentication, but conventional alongside it.) A Referrer-Policy header + would cover the whole admin, but that belongs to the host application, not to a plugin. #} + {% if is_connected %} + {% set csrf_token_id = constant('PayPlug\\SyliusPayPlugPlugin\\Action\\Admin\\Auth\\UnifiedLogoutController::CSRF_TOKEN_ID_PREFIX') ~ payment_method.id %} + + {{ 'payplug_sylius_payplug_plugin.ui.logout'|trans }} + +
{{ 'payplug_sylius_payplug_plugin.ui.logout_help'|trans }}
+ {% endif %} +
diff --git a/templates/admin/payment_method/form/hf_identifier.html.twig b/templates/admin/payment_method/form/hf_identifier.html.twig new file mode 100644 index 00000000..0942b7be --- /dev/null +++ b/templates/admin/payment_method/form/hf_identifier.html.twig @@ -0,0 +1,5 @@ +{% set form = hookable_metadata.context.form.gatewayConfig.config.hfIdentifier %} + +
+ {{ form_row(form) }} +
diff --git a/templates/admin/payment_method/form/integrated_payment.html.twig b/templates/admin/payment_method/form/hosted_fields_mode.html.twig similarity index 80% rename from templates/admin/payment_method/form/integrated_payment.html.twig rename to templates/admin/payment_method/form/hosted_fields_mode.html.twig index 26c8c373..f63517fd 100644 --- a/templates/admin/payment_method/form/integrated_payment.html.twig +++ b/templates/admin/payment_method/form/hosted_fields_mode.html.twig @@ -1,5 +1,5 @@ -{% set form = hookable_metadata.context.form.gatewayConfig.config.integratedPayment %} +{% set form = hookable_metadata.context.form.gatewayConfig.config.hostedFieldsMode %}
{{ form_row(form) }} -
\ No newline at end of file +
diff --git a/templates/admin/payment_method/form/scalapay_amount_range.html.twig b/templates/admin/payment_method/form/scalapay_amount_range.html.twig new file mode 100644 index 00000000..6a0c0b3b --- /dev/null +++ b/templates/admin/payment_method/form/scalapay_amount_range.html.twig @@ -0,0 +1,9 @@ +{% set min_amount_form = hookable_metadata.context.form.gatewayConfig.config.min_amount %} +{% set max_amount_form = hookable_metadata.context.form.gatewayConfig.config.max_amount %} + +
+ {{ form_row(min_amount_form) }} +
+
+ {{ form_row(max_amount_form) }} +
diff --git a/templates/form/sylius_checkout_select_payment_row.html.twig b/templates/form/sylius_checkout_select_payment_row.html.twig index 6c27adff..681c1183 100644 --- a/templates/form/sylius_checkout_select_payment_row.html.twig +++ b/templates/form/sylius_checkout_select_payment_row.html.twig @@ -80,7 +80,8 @@ class="payplug-payment-choice__input payment-choice__input" {{ stimulus_action('@payplug/sylius-payplug-plugin/checkout-select-payment', 'enableNextStepButton', 'change') | - stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleHide', 'change') + stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleHide', 'change') | + stimulus_action('@payplug/sylius-payplug-plugin/hosted-fields', 'handleHide', 'change') }} {% if form.vars.value is not empty %} {{ form.vars.value == card.id ? 'checked="checked"' : '' }} @@ -103,7 +104,10 @@ id="payplug_choice_card_other" name="{{ form.vars.full_name }}" class="payplug-payment-choice__input payment-choice__input" - {{ stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleShow', 'change') }} + {{ + stimulus_action('@payplug/sylius-payplug-plugin/integrated-payment', 'handleShow', 'change') | + stimulus_action('@payplug/sylius-payplug-plugin/hosted-fields', 'handleShow', 'change') + }} {% if form.vars.value is not empty %} {{ form.vars.value == 'other' ? 'checked="checked"' : '' }} {% elseif sylius.customer.cards is empty %} diff --git a/templates/shop/hosted_fields/index.html.twig b/templates/shop/hosted_fields/index.html.twig new file mode 100644 index 00000000..8dc16b9f --- /dev/null +++ b/templates/shop/hosted_fields/index.html.twig @@ -0,0 +1,78 @@ + + +
+
+ {{ 'sylius.ui.loading'|trans }} +
+
+ VISA + MC +
+
+
+ {# The SDK draws its own "select your network" label as an absolutely positioned
+ in , with pixel coordinates measured once (they go stale as soon as the layout moves). CSS hides + it and shows this in-flow copy instead, only while the SDK reveals its own (see hosted-fields.css). #} +
+ + {{ 'payplug_sylius_payplug_plugin.ui.hosted_fields.network_selection.label'|trans }} + + + {{ 'payplug_sylius_payplug_plugin.ui.hosted_fields.network_selection.tooltip'|trans }} + + + +
+
+
+
+
+
+ {% if is_save_card_enabled(paymentMethod) %} +
+ {# No name to not trigger LiveComponent #} + +
+ {% endif %} +
+ +
+
+ + + + + + + + diff --git a/templates/shop/select_payment/_payplug.html.twig b/templates/shop/select_payment/_payplug.html.twig index cb5fa9e5..e7a2630a 100644 --- a/templates/shop/select_payment/_payplug.html.twig +++ b/templates/shop/select_payment/_payplug.html.twig @@ -1,38 +1,33 @@ {% set form = hookable_metadata.context.form %} {% set method = hookable_metadata.context.method %} {% set order = hookable_metadata.context.order %} -{% set factoryName = method.gatewayConfig.factoryName %} -{% set code = method.code %} -{% set payplugFactoryName = constant('PayPlug\\SyliusPayPlugPlugin\\Gateway\\PayplugGatewayFactory::FACTORY_NAME') %} -{% set checkboxClass = 'checkbox-payplug' %} - -{% set hasSavedCards = false %} -{% if is_granted('ROLE_USER') - and form.parent.parent.payplug_card_choice is defined - and is_save_card_enabled(method) - and sylius.customer.cards is not empty -%} - {% set hasSavedCards = true %} +{% set has_saved_cards = false %} +{% if is_granted('ROLE_USER') and form.parent.parent.payplug_card_choice is defined and is_save_card_enabled(method) and sylius.customer.cards is not empty %} + {% set has_saved_cards = true %} {% endif %} -{% set integratedPayment = false %} -{% if method.gatewayConfig.config.integratedPayment is defined and method.gatewayConfig.config.integratedPayment is same as true %} - {% set integratedPayment = true %} -{% endif %} +{% set display_mode = payplug_display_mode(method.gatewayConfig.config) %} +{% set is_hosted_fields = display_mode == 'hosted_fields' %} +{% set is_integrated_payment = display_mode == 'integrated_payment' %} -
- {% if hasSavedCards %} +
+ {% if has_saved_cards %}
{{ form_row(form.parent.parent.payplug_card_choice) }}
{% endif %} - {% if integratedPayment %} + {% if is_hosted_fields %} + {% include '@PayPlugSyliusPayPlugPlugin/shop/hosted_fields/index.html.twig' with { + 'paymentMethod': method, + 'hasSavedCards': has_saved_cards, + } %} + {% elseif is_integrated_payment %} {% include '@PayPlugSyliusPayPlugPlugin/shop/integrated/index.html.twig' with { 'paymentMethod': method, 'payment': order.getLastPayment('cart'), - 'hasSavedCards': hasSavedCards, + 'hasSavedCards': has_saved_cards, 'paymentInputId': form.vars.id, } %} {% endif %} diff --git a/tests/Behat/Context/Setup/PayPlugContext.php b/tests/Behat/Context/Setup/PayPlugContext.php index 493e38b6..879059da 100644 --- a/tests/Behat/Context/Setup/PayPlugContext.php +++ b/tests/Behat/Context/Setup/PayPlugContext.php @@ -67,6 +67,30 @@ public function theStoreHasAPaymentMethodWithACodeAndPayPlugPaymentGateway( $this->paymentMethodManager->flush(); } + /** + * @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and PayPlug Hosted Fields payment gateway + */ + public function theStoreHasAPaymentMethodWithACodeAndPayPlugHostedFieldsPaymentGateway( + string $paymentMethodName, + string $paymentMethodCode, + ): void { + $paymentMethod = $this->createPaymentMethodPayPlug( + $paymentMethodName, + $paymentMethodCode, + PayPlugGatewayFactory::FACTORY_NAME, + PayPlugGatewayFactory::FACTORY_TITLE, + ); + + $paymentMethod->getGatewayConfig()->setConfig([ + 'secretKey' => 'test', + 'payum.http_client' => '@payplug_sylius_payplug_plugin.api_client.payplug', + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'test-company-id', + ]); + + $this->paymentMethodManager->flush(); + } + /** * @Given the store has a payment method :paymentMethodName with a code :paymentMethodCode and Oney payment gateway */ diff --git a/tests/Behat/Context/Ui/Shop/CheckoutContext.php b/tests/Behat/Context/Ui/Shop/CheckoutContext.php index ae35eef1..85b10294 100644 --- a/tests/Behat/Context/Ui/Shop/CheckoutContext.php +++ b/tests/Behat/Context/Ui/Shop/CheckoutContext.php @@ -4,7 +4,7 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\Behat\Context\Ui\Shop; -use Behat\Behat\Context\Context; +use Behat\MinkExtension\Context\RawMinkContext; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use Sylius\Behat\Page\Shop\Checkout\CompletePageInterface; use Sylius\Behat\Page\Shop\Order\ShowPageInterface; @@ -13,7 +13,7 @@ use Tests\PayPlug\SyliusPayPlugPlugin\Behat\Page\Shop\Payum\PaymentPageInterface; use Webmozart\Assert\Assert; -final class CheckoutContext implements Context +final class CheckoutContext extends RawMinkContext { /** @var CompletePageInterface */ private $summaryPage; @@ -157,4 +157,15 @@ public function oneyIsDisabled(): void { $this->payPlugApiMocker->disableOney(); } + + /** + * @Then I should see the :selector element on the page + */ + public function iShouldSeeTheElementOnThePage(string $selector): void + { + Assert::notNull( + $this->getSession()->getPage()->find('css', $selector), + sprintf('Element matching selector "%s" was not found on the page.', $selector), + ); + } } diff --git a/tests/Behat/Mocker/PayPlugApiClientFactory.php b/tests/Behat/Mocker/PayPlugApiClientFactory.php index a143f64d..913e7313 100644 --- a/tests/Behat/Mocker/PayPlugApiClientFactory.php +++ b/tests/Behat/Mocker/PayPlugApiClientFactory.php @@ -6,6 +6,7 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; +use Sylius\Component\Payment\Model\PaymentMethodInterface; use Symfony\Component\DependencyInjection\ContainerInterface; class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface @@ -22,7 +23,11 @@ public function __construct(ContainerInterface $container, string $serviceName) $this->serviceName = $serviceName; } - public function create(string $factoryName, ?string $key = null): PayPlugApiClientInterface + /** + * The Behat suites stub one PayPlug account for the whole scenario, so the payment method is + * ignored here — the mocked client is the same whichever one is passed. + */ + public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface { return new PayPlugApiClient($this->container, $this->serviceName); } diff --git a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml index 659d5003..df97f112 100644 --- a/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml +++ b/tests/Behat/Resources/suites/ui/paying_with_payplug_for_order.yml @@ -30,6 +30,9 @@ default: - sylius.behat.context.setup.user - payplug_sylius_payplug_plugin.behat.context.setup.payplug + # Provides "This secret Key is valid", which installs the static Payplug\Core\HttpClient + # mock for the whole test process (needed by templates calling is_payplug_test_mode_enabled). + - payplug_sylius_payplug_plugin.behat.context.ui.admin.managing_payment_method_payplug # - sylius.behat.context.ui.paypal - sylius.behat.context.ui.shop.cart diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php new file mode 100644 index 00000000..4d534bb0 --- /dev/null +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -0,0 +1,425 @@ +router = $this->createMock(RouterInterface::class); + $this->paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->validator = $this->createMock(ValidatorInterface::class); + // final class — cannot be mocked by PHPUnit, so a real instance wired with mocked + // collaborators is built instead. Most scenarios here stop before process() is reached; + // the happy-path ones do reach it, hence the stubbed validator returning no violations. + $this->paymentMethodValidator = new PaymentMethodValidator( + $this->createMock(RequestStack::class), + $this->validator, + $this->entityManager, + ); + $this->logger = $this->createMock(LoggerInterface::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + + $this->controller = new UnifiedAuthenticationController( + $this->router, + $this->paymentMethodRepository, + $this->entityManager, + $this->paymentMethodValidator, + $this->logger, + $this->oauthHttpClient, + new IdTokenEmailExtractor(), + 'https://api-qa.payplug.com', + 'https://www.payplug.com', + ); + + $this->controller->setContainer(new ServiceLocator([ + 'router' => fn () => $this->router, + ])); + } + + protected function tearDown(): void + { + HttpClient::$REQUEST_HANDLER = null; + } + + private function buildRequest(array $query = []): Request + { + $request = new Request($query); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + /** + * PHPUnit resolves multiple `method('generate')->with(...)` stubs by registration order, not + * by which constraint actually matches a given call — a single callback branching on the + * route name is the only way to give different routes different return values reliably. + * + * @param array $routeUrls route name => URL to return + * @param array $throwForRoutes route names that should throw instead + */ + private function stubRouterGenerate(array $routeUrls, array $throwForRoutes = []): void + { + $this->router->method('generate')->willReturnCallback( + function (string $route) use ($routeUrls, $throwForRoutes): string { + if (\in_array($route, $throwForRoutes, true)) { + throw new \RuntimeException('router exploded for route ' . $route); + } + + return $routeUrls[$route] ?? '/admin/payment-methods'; + }, + ); + } + + // ------------------------------------------------------------------------- + // setupRedirection() — happy path + // ------------------------------------------------------------------------- + + public function testSetupRedirection_buildsAuthorizationUrlAndStoresPkceStateInSession(): void + { + $this->stubRouterGenerate(['payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback']); + + $request = $this->buildRequest(['client_id' => 'client_abc', 'company_id' => 'company_xyz']); + + $response = $this->controller->setupRedirection($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertStringStartsWith('https://api-qa.payplug.com/oauth2/auth?', $response->getTargetUrl()); + self::assertStringContainsString('client_id=client_abc', $response->getTargetUrl()); + self::assertStringContainsString('audience=' . urlencode('https://www.payplug.com'), $response->getTargetUrl()); + + $session = $request->getSession(); + self::assertSame('client_abc', $session->get('payplug_client_id')); + self::assertSame('company_xyz', $session->get('payplug_company_id')); + self::assertNotNull($session->get('payplug_oauth_state')); + self::assertNotNull($session->get('payplug_oauth_code_verifier')); + } + + // ------------------------------------------------------------------------- + // setupRedirection() — failure redirects to payment method index (no id in session yet) + // ------------------------------------------------------------------------- + + public function testSetupRedirection_onFailure_logsAndRedirectsToPaymentMethodIndex(): void + { + $this->stubRouterGenerate( + ['sylius_admin_payment_method_index' => '/admin/payment-methods'], + throwForRoutes: ['payplug_sylius_admin_auth_oauth_callback'], + ); + + $this->logger->expects(self::once())->method('critical') + ->with('Error while perform Payplug OAuth Setup redirection', self::anything()) + ; + + $request = $this->buildRequest(['client_id' => 'client_abc']); + + $response = $this->controller->setupRedirection($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('payplug_sylius_payplug_plugin.admin.oauth_setup_error', $request->getSession()->getFlashBag()->peek('error')[0] ?? null); + } + + // ------------------------------------------------------------------------- + // oauthCallback() — state mismatch is rejected before any token exchange + // ------------------------------------------------------------------------- + + public function testOauthCallback_withMismatchedState_rejectsBeforeExchangingToken(): void + { + $this->stubRouterGenerate([]); + $this->oauthHttpClient->expects(self::never())->method('post'); + $this->logger->expects(self::once())->method('critical') + ->with('Error while perform Payplug OAuth callback', self::anything()) + ; + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'attacker-state']); + $request->getSession()->set('payplug_oauth_state', 'real-state'); + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testOauthCallback_withNoStateInSession_rejectsBeforeExchangingToken(): void + { + $this->stubRouterGenerate([]); + // Session never went through setupRedirection() (e.g. expired) — no expected state at all. + $this->oauthHttpClient->expects(self::never())->method('post'); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'some-state']); + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testOauthCallback_withEmptyState_rejectsBeforeExchangingToken(): void + { + $this->stubRouterGenerate([]); + $this->oauthHttpClient->expects(self::never())->method('post'); + + $request = $this->buildRequest(['code' => 'auth_code']); // no "state" query param at all + $request->getSession()->set('payplug_oauth_state', ''); + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + // ------------------------------------------------------------------------- + // oauthCallback() — valid state, but no client id in session + // ------------------------------------------------------------------------- + + /** + * A missing/non-string client_id (e.g. session expired, or setupRedirection() was never hit) + * must be rejected before exchangeAuthorizationCode() is called, the same way a state mismatch + * already is — otherwise it falls through to a TypeError, logged as a noisy "critical" for + * what's really just an expired session. + */ + public function testOauthCallback_withMissingClientId_rejectsBeforeExchangingToken(): void + { + $this->stubRouterGenerate([]); + $this->oauthHttpClient->expects(self::never())->method('post'); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); + $request->getSession()->set('payplug_oauth_state', 'matching-state'); + $request->getSession()->set('payplug_oauth_code_verifier', 'verifier_123'); + // Deliberately no 'payplug_client_id' set. + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + // ------------------------------------------------------------------------- + // oauthCallback() — valid state, but no code verifier in session + // ------------------------------------------------------------------------- + + /** + * A missing/non-string code_verifier (e.g. session expired, or setupRedirection() was never + * hit) must be rejected before exchangeAuthorizationCode() is called, the same way a state + * mismatch already is — otherwise it falls through to a TypeError, logged as a noisy + * "critical" for what's really just an expired session. + */ + public function testOauthCallback_withMissingCodeVerifier_rejectsBeforeExchangingToken(): void + { + $this->stubRouterGenerate([]); + $this->oauthHttpClient->expects(self::never())->method('post'); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); + $request->getSession()->set('payplug_oauth_state', 'matching-state'); + // Deliberately no 'payplug_oauth_code_verifier' set. + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + // ------------------------------------------------------------------------- + // oauthCallback() — valid state, but no payment method id in session + // ------------------------------------------------------------------------- + + public function testOauthCallback_withValidStateButNoPaymentMethodIdInSession_stopsAfterTokenExchange(): void + { + $this->stubRouterGenerate(['payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback']); + + $this->oauthHttpClient->expects(self::once())->method('post')->willReturn([ + 'status' => 200, + 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 3600, 'token_type' => 'Bearer']), + ]); + + // Never reached: the "no payment method id" guard throws first. + $this->paymentMethodRepository->expects(self::never())->method('find'); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); + $request->getSession()->set('payplug_client_id', 'client_abc'); + $request->getSession()->set('payplug_oauth_state', 'matching-state'); + $request->getSession()->set('payplug_oauth_code_verifier', 'verifier_123'); + // Deliberately no 'payplug_sylius_oauth_payment_method_id' set. + + $response = $this->controller->oauthCallback($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('payplug_sylius_payplug_plugin.admin.oauth_setup_error', $request->getSession()->getFlashBag()->peek('error')[0] ?? null); + } + + // ------------------------------------------------------------------------- + // oauthCallback() — the connected account's email (PRE-3631) + // ------------------------------------------------------------------------- + + /** + * Builds a JWT-shaped id token carrying the given claims. The signature segment is a + * placeholder — the controller reads the payload for display and never verifies it. + * + * @param array $claims + */ + private function idTokenWithClaims(array $claims): string + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + return $encode('{"alg":"RS256"}') . '.' . $encode((string) json_encode($claims)) . '.c2ln'; + } + + /** + * Drives oauthCallback() all the way through a successful login and returns the gateway config + * as it stands afterwards. $tokenResponse is the identity provider's token-endpoint payload. + * + * @param array $tokenResponse + * @param array $initialConfig gateway config as it stands before the login + * + * @return array + */ + private function runSuccessfulCallback(array $tokenResponse, array $initialConfig = []): array + { + $this->stubRouterGenerate([ + 'payplug_sylius_admin_auth_oauth_callback' => 'https://shop.example.com/payplug/auth/oauth-callback', + 'sylius_admin_payment_method_update' => '/admin/payment-methods/1/edit', + ]); + + $this->oauthHttpClient->method('post')->willReturn([ + 'status' => 200, + 'body' => json_encode($tokenResponse), + ]); + + // Answers both createClientIdAndSecret() calls (test then live) without touching the network. + HttpClient::$REQUEST_HANDLER = new FakePayplugHttpRequest([ + json_encode(['client_id' => 'generated_client', 'client_secret' => 'generated_secret']), + ]); + + $storedConfig = $initialConfig; + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturnCallback(static fn (): array => $storedConfig); + $gatewayConfig->method('setConfig')->willReturnCallback( + static function (array $config) use (&$storedConfig): void { + $storedConfig = $config; + }, + ); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getName')->willReturn('Carte bancaire'); + $paymentMethod->method('getId')->willReturn(1); + + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + $this->validator->method('validate')->willReturn(new ConstraintViolationList()); + + $request = $this->buildRequest(['code' => 'auth_code', 'state' => 'matching-state']); + $session = $request->getSession(); + $session->set('payplug_client_id', 'client_abc'); + $session->set('payplug_company_id', 'company_xyz'); + $session->set('payplug_oauth_state', 'matching-state'); + $session->set('payplug_oauth_code_verifier', 'verifier_123'); + $session->set('payplug_sylius_oauth_payment_method_id', 1); + + $this->logger->expects(self::never())->method('critical'); + + $this->controller->oauthCallback($request); + + return $storedConfig; + } + + /** + * The `/account` endpoint carries no email — the id_token from this exchange is the only place + * the connected merchant's address appears, and it is gone once the callback returns, so it has + * to be persisted here for the admin screen to have anything to show. + */ + public function testOauthCallback_storesTheEmailFromTheIdTokenOnTheGatewayConfig(): void + { + $config = $this->runSuccessfulCallback([ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + 'id_token' => $this->idTokenWithClaims(['email' => 'merchant@example.com']), + ]); + + self::assertSame('merchant@example.com', $config['account_email'] ?? null); + } + + /** + * The credentials are what the login exists to produce; the email is a display nicety captured + * alongside them. An identity provider that returns no id_token must therefore still yield a + * fully configured gateway. + */ + public function testOauthCallback_withoutAnIdToken_stillStoresTheClientCredentials(): void + { + $config = $this->runSuccessfulCallback([ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ]); + + self::assertNull($config['account_email'] ?? null); + self::assertSame('generated_client', $config['live_client']['client_id'] ?? null); + self::assertSame('generated_client', $config['test_client']['client_id'] ?? null); + } + + /** + * Re-authenticating against a different PayPlug account must not leave the previous account's + * address on screen — a stale email here would misreport which account takes the money. + */ + public function testOauthCallback_overwritesAPreviouslyStoredEmail(): void + { + $config = $this->runSuccessfulCallback( + [ + 'access_token' => 'jwt', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + 'id_token' => $this->idTokenWithClaims(['email' => 'new-owner@example.com']), + ], + ['account_email' => 'previous-owner@example.com'], + ); + + self::assertSame('new-owner@example.com', $config['account_email'] ?? null); + } +} diff --git a/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php b/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php new file mode 100644 index 00000000..8da4c54d --- /dev/null +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedLogoutControllerTest.php @@ -0,0 +1,246 @@ + would be invalid HTML — so the CSRF check is the only thing standing + * between a crafted link and a merchant losing a connection. Several tests below exist purely to + * pin that: a request that fails the check must mutate nothing at all. + */ +final class UnifiedLogoutControllerTest extends TestCase +{ + private const VALID_TOKEN = 'a-valid-csrf-token'; + + private RouterInterface&MockObject $router; + + private RepositoryInterface&MockObject $paymentMethodRepository; + + private LoggerInterface&MockObject $logger; + + private SyliusTokenCache $tokenCache; + + private UnifiedLogoutController $controller; + + protected function setUp(): void + { + $this->router = $this->createMock(RouterInterface::class); + $this->paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tokenCache = new SyliusTokenCache(new ArrayAdapter()); + + $this->router->method('generate')->willReturnCallback( + static fn (string $route, array $parameters = []): string => '/admin/route/' . $route . '/' . ($parameters['id'] ?? ''), + ); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturnCallback( + static fn (CsrfToken $token): bool => self::VALID_TOKEN === $token->getValue(), + ); + + $this->controller = new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($this->createMock(EntityManagerInterface::class), $this->tokenCache), + $csrfTokenManager, + $this->logger, + ); + } + + public function testLogout_clearsTheConnectionOfTheTargetedGatewayConfig(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertArrayNotHasKey('live_client', $config); + self::assertArrayNotHasKey('test_client', $config); + self::assertArrayNotHasKey('account_email', $config); + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testLogout_redirectsBackToThePaymentMethodUpdateScreenWithASuccessFlash(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod()); + $request = $this->buildRequest(self::VALID_TOKEN); + + $response = $this->controller->logout($request, 42); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/admin/route/sylius_admin_payment_method_update/42', $response->getTargetUrl()); + self::assertSame( + 'payplug_sylius_payplug_plugin.admin.logout_success', + $request->getSession()->getFlashBag()->peek('success')[0] ?? null, + ); + } + + public function testLogout_invalidCsrfToken_isRejectedAndChangesNothing(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + try { + $this->controller->logout($this->buildRequest('forged-token'), 42); + self::fail('Expected a BadRequestHttpException for an invalid CSRF token.'); + } catch (BadRequestHttpException) { + // expected + } + + self::assertArrayHasKey('live_client', $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + self::assertTrue($paymentMethod->isEnabled()); + } + + public function testLogout_missingCsrfToken_isRejected(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod()); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->logout($this->buildRequest(null), 42); + } + + /** + * `security.csrf.token_manager` only exists while CSRF protection is enabled, so the dependency + * is optional and the check is skipped when it is absent — the same posture as Sylius's own + * token-in-the-query-string admin actions, which guard every check with + * sylius_csrf_protection_enabled(). An app that turns CSRF off has made that call for itself; + * the button must still work there rather than 400 on every click. + */ + public function testLogout_whenCsrfProtectionIsDisabled_proceedsWithoutAToken(): void + { + $paymentMethod = $this->connectedPaymentMethod(); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + $controller = new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($this->createMock(EntityManagerInterface::class), $this->tokenCache), + null, + $this->logger, + ); + + $controller->logout($this->buildRequest(null), 42); + + self::assertArrayNotHasKey('live_client', $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testLogout_unknownPaymentMethod_isNotFound(): void + { + $this->paymentMethodRepository->method('find')->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + } + + public function testLogout_paymentMethodOfAnotherProvider_isNotFound(): void + { + $this->paymentMethodRepository->method('find')->willReturn($this->connectedPaymentMethod('stripe')); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->logout($this->buildRequest(self::VALID_TOKEN), 42); + } + + public function testLogout_whenRevokingFails_logsAndRedirectsWithAnErrorFlash(): void + { + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($this->gatewayConfig(PayPlugGatewayFactory::FACTORY_NAME)); + $this->paymentMethodRepository->method('find')->willReturn($paymentMethod); + + $this->logger->expects(self::once())->method('critical') + ->with('Error while logging out the Payplug gateway', self::anything()) + ; + + $request = $this->buildRequest(self::VALID_TOKEN); + $response = $this->controllerWithFailingRevoker()->logout($request, 42); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame( + 'payplug_sylius_payplug_plugin.admin.logout_error', + $request->getSession()->getFlashBag()->peek('error')[0] ?? null, + ); + } + + /** + * GatewayConnectionRevoker is `final` and cannot be mocked, so the failure is injected one + * level down: a real revoker over an entity manager whose flush() throws, which is what a + * database error during logout actually looks like. + */ + private function controllerWithFailingRevoker(): UnifiedLogoutController + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('flush')->willThrowException(new \RuntimeException('database exploded')); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturn(true); + + return new UnifiedLogoutController( + $this->router, + $this->paymentMethodRepository, + new GatewayConnectionRevoker($entityManager, $this->tokenCache), + $csrfTokenManager, + $this->logger, + ); + } + + private function buildRequest(?string $csrfToken): Request + { + $request = new Request(null === $csrfToken ? [] : ['_csrf_token' => $csrfToken]); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + private function connectedPaymentMethod( + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + ): PaymentMethodInterface + { + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($this->gatewayConfig($factoryName)); + $paymentMethod->enable(); + + return $paymentMethod; + } + + private function gatewayConfig(string $factoryName): GatewayConfig + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setConfig([ + 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'], + 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'], + 'account_email' => 'merchant@example.com', + ]); + + return $gatewayConfig; + } +} diff --git a/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php b/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php new file mode 100644 index 00000000..88e789f8 --- /dev/null +++ b/tests/PHPUnit/Action/Admin/AuthorizedPaymentControllerTest.php @@ -0,0 +1,429 @@ +operator = $this->createMock(AuthorizationOperatorInterface::class); + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $this->authorizationChecker->method('isGranted')->with('ROLE_ADMINISTRATION_ACCESS')->willReturn(true); + $this->logger = $this->createMock(LoggerInterface::class); + + $lock = $this->createMock(ILock::class); + $lock->method('acquire')->willReturn(true); + $stateMachine = $this->createMock(StateMachineInterface::class); + $stateMachine->method('can')->willReturn(true); + + $router = $this->createMock(RouterInterface::class); + $router->method('generate')->willReturnCallback( + static fn (string $route, array $parameters = []): string => '/admin/' . $route . '/' . ($parameters['id'] ?? ''), + ); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->method('isTokenValid')->willReturnCallback( + static fn (CsrfToken $token): bool => 'payplug_authorization_42' === $token->getId() && self::VALID_TOKEN === $token->getValue(), + ); + + $this->controller = new AuthorizedPaymentController( + $this->paymentRepository, + new AuthorizedPaymentOperationProcessor( + $this->operator, + $lock, + $this->entityManager, + $stateMachine, + new MockClock('2026-09-24T10:00:00+00:00'), + $this->createMock(LoggerInterface::class), + ), + $router, + $this->authorizationChecker, + $csrfTokenManager, + $this->logger, + ); + } + + /** + * @return iterable + */ + public static function amounts(): iterable + { + yield 'blank means the whole remainder' => ['', null]; + yield 'integer' => ['12', 1200]; + yield 'dot decimal' => ['12.5', 1250]; + yield 'comma decimal' => ['12,50', 1250]; + yield 'thousands space' => ['1 234,56', 123456]; + yield 'too many decimals is refused downstream' => ['12.345', 0]; + yield 'negative is refused downstream' => ['-5', 0]; + yield 'garbage is refused downstream' => ['abc', 0]; + yield 'nine digits is the ceiling' => ['999999999', 99999999900]; + yield 'ten digits is refused downstream' => ['1234567890', 0]; + yield 'an overflowing amount is refused, not a TypeError' => ['99999999999999999999', 0]; + } + + /** + * @dataProvider amounts + */ + public function testParseAmount(string $raw, ?int $expected): void + { + self::assertSame($expected, AuthorizedPaymentController::parseAmount($raw, 'EUR')); + } + + public function testParseAmount_isTheSameForAnyTwoDecimalCurrencyWhateverItsCase(): void + { + self::assertSame(1250, AuthorizedPaymentController::parseAmount('12,50', 'USD')); + self::assertSame(1250, AuthorizedPaymentController::parseAmount('12,50', 'eur')); + } + + /** + * @return iterable + */ + public static function unsupportedCurrencies(): iterable + { + yield 'zero-decimal currency' => ['JPY']; + yield 'another zero-decimal currency' => ['XPF']; + yield 'three-decimal currency' => ['KWD']; + yield 'empty currency' => ['']; + yield 'malformed currency' => ['EU']; + yield 'currency with a digit' => ['E1R']; + } + + /** + * @dataProvider unsupportedCurrencies + */ + public function testParseAmount_ofATypedAmountInAnUnsupportedCurrency_isRefused(string $currency): void + { + try { + AuthorizedPaymentController::parseAmount('12.50', $currency); + self::fail('Expected the amount to be refused.'); + } catch (AuthorizationOperationException $exception) { + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.unsupported_currency', $exception->getTranslationKey()); + self::assertSame(['%currency%' => $currency], $exception->getTranslationParameters()); + } + } + + /** + * @dataProvider unsupportedCurrencies + */ + public function testParseAmount_ofABlankAmountInAnUnsupportedCurrency_stillMeansTheWholeRemainder( + string $currency, + ): void + { + self::assertNull(AuthorizedPaymentController::parseAmount(" \u{00A0}", $currency)); + } + + public function testParseAmount_ofGarbageInAnUnsupportedCurrency_isStillAnInvalidAmount(): void + { + self::assertSame(0, AuthorizedPaymentController::parseAmount('abc', 'JPY')); + } + + /** + * @return iterable + */ + public static function typedAmountsInUnsupportedCurrencies(): iterable + { + yield 'capture in a zero-decimal currency' => ['capture', 'JPY']; + yield 'cancel in a three-decimal currency' => ['cancel', 'KWD']; + yield 'capture without a currency' => ['capture', '']; + yield 'cancel with a malformed currency' => ['cancel', 'EU']; + } + + /** + * @dataProvider typedAmountsInUnsupportedCurrencies + */ + public function testATypedAmountInAnUnsupportedCurrency_isRefusedBeforeUpcAndRecordsNothing( + string $action, + string $currency, + ): void + { + $payment = $this->authorizedPayment($currency); + $detailsBefore = $payment->getDetails(); + $this->paymentRepository->method('find')->willReturn($payment); + $this->operator->expects(self::never())->method('capture'); + $this->operator->expects(self::never())->method('cancel'); + $this->entityManager->expects(self::never())->method('flush'); + $this->logger->expects(self::once())->method('warning')->with( + self::stringContains('currency'), + ['sylius_payment_id' => 42, 'action' => $action, 'currency' => $currency], + ); + + $request = $this->request(['amount' => '5']); + $response = 'capture' === $action ? $this->controller->capture($request, 7, 42) : $this->controller->cancel($request, 7, 42); + + self::assertSame('/admin/sylius_admin_order_show/7', $response->headers->get('Location')); + self::assertSame([[ + 'message' => 'payplug_sylius_payplug_plugin.admin.authorization.error.unsupported_currency', + 'parameters' => ['%currency%' => $currency], + ]], $this->flashes($request, 'error')); + self::assertSame($detailsBefore, $payment->getDetails()); + self::assertSame(PaymentInterface::STATE_AUTHORIZED, $payment->getState()); + } + + public function testCapture_ofTheWholeRemainderInAZeroDecimalCurrency_isSentAsBefore(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment('JPY')); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', self::anything(), 1000, 'JPY') + ->willReturn(new CaptureOutput(200, '{"operationIds":["op_c1"]}', null, null, null)); + $this->logger->expects(self::never())->method('warning'); + + $request = $this->request(['amount' => '']); + $this->controller->capture($request, 7, 42); + + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.capture_success'], $this->flashes($request, 'success')); + } + + public function testCapture_aboveTheRemainderInUsd_showsTheRemainingAmountInUsd(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment('USD')); + + $request = $this->request(['amount' => '50']); + $this->controller->capture($request, 7, 42); + + self::assertSame([[ + 'message' => 'payplug_sylius_payplug_plugin.admin.authorization.error.amount_exceeds_remaining', + 'parameters' => ['%remaining%' => '10.00 USD'], + ]], $this->flashes($request, 'error')); + } + + public function testCapture_capturesTheTypedAmountFlushesAndRedirectsToTheOrder(): void + { + $payment = $this->authorizedPayment(); + $this->paymentRepository->method('find')->with(42)->willReturn($payment); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', self::anything(), 250) + ->willReturn(new CaptureOutput(200, '{"operationIds":["op_c1"]}', null, null, null)); + $this->entityManager->expects(self::once())->method('flush'); + + $request = $this->request(['amount' => '2,50', 'version' => '0']); + $response = $this->controller->capture($request, 7, 42); + + self::assertSame('/admin/sylius_admin_order_show/7', $response->headers->get('Location')); + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.capture_success'], $this->flashes($request, 'success')); + self::assertSame(250, AuthorizationDetails::fromDetails($payment->getDetails())->capturedAmount()); + } + + public function testCapture_refusedByUpc_showsTheReasonAndFlushesNothing(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->method('capture')->willThrowException(new AuthorizationExpiredException('expired')); + $this->entityManager->expects(self::never())->method('flush'); + + $request = $this->request(['amount' => '']); + $this->controller->capture($request, 7, 42); + + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.error.expired'], $this->flashes($request, 'error')); + } + + public function testCapture_aboveTheRemainder_showsTheRemainingAmount(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + + $request = $this->request(['amount' => '50']); + $this->controller->capture($request, 7, 42); + + self::assertSame([[ + 'message' => 'payplug_sylius_payplug_plugin.admin.authorization.error.amount_exceeds_remaining', + 'parameters' => ['%remaining%' => '10.00 EUR'], + ]], $this->flashes($request, 'error')); + } + + public function testCapture_withAnInvalidCsrfToken_movesNoMoney(): void + { + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->capture($this->request(['amount' => ''], 'forged'), 7, 42); + } + + public function testCapture_withoutAdminRole_movesNoMoney(): void + { + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted')->willReturn(false); + $controller = new AuthorizedPaymentController( + $this->paymentRepository, + new AuthorizedPaymentOperationProcessor($this->operator, $this->createMock(ILock::class), $this->entityManager, $this->createMock(StateMachineInterface::class), new MockClock(), $this->createMock(LoggerInterface::class)), + $this->createMock(RouterInterface::class), + $authorizationChecker, + null, + $this->createMock(LoggerInterface::class), + ); + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(AccessDeniedHttpException::class); + + $controller->capture($this->request(['amount' => '']), 7, 42); + } + + public function testCapture_ofAPaymentFromAnotherOrder_is404(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(NotFoundHttpException::class); + + $this->controller->capture($this->request(['amount' => '']), 8, 42); + } + + public function testCapture_withoutAVersion_isABadRequestAndMovesNoMoney(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->expects(self::never())->method('capture'); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->capture($this->request(['amount' => ''], self::VALID_TOKEN, false), 7, 42); + } + + public function testCancel_withANonNumericVersion_isABadRequestAndMovesNoMoney(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->expects(self::never())->method('cancel'); + + $this->expectException(BadRequestHttpException::class); + + $this->controller->cancel($this->request(['amount' => '', 'version' => 'abc']), 7, 42); + } + + public function testCapture_withAnOverflowingAmount_isRefusedAsInvalidWithoutCallingUpc(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->expects(self::never())->method('capture'); + + $request = $this->request(['amount' => '99999999999999999999', 'version' => '0']); + $this->controller->capture($request, 7, 42); + + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.error.invalid_amount'], $this->flashes($request, 'error')); + } + + public function testCapture_whenTheFlushFailsAfterUpcAccepted_tellsTheMerchantToCheckBeforeRetrying(): void + { + $this->paymentRepository->method('find')->willReturn($this->authorizedPayment()); + $this->operator->method('capture')->willReturn(new CaptureOutput(200, '{"operationIds":["op_c1"]}', null, null, null)); + $this->entityManager->method('flush')->willThrowException(new \RuntimeException('connection lost')); + + $request = $this->request(['amount' => '', 'version' => '0']); + $this->controller->capture($request, 7, 42); + + // Not "the payment was not changed": UPC did accept the capture. + self::assertSame(['payplug_sylius_payplug_plugin.admin.authorization.error.unexpected_error'], $this->flashes($request, 'error')); + } + + /** + * @param array $body + */ + private function request(array $body, string $csrfToken = self::VALID_TOKEN, bool $withVersion = true): Request + { + if ($withVersion) { + $body += ['version' => '0']; + } + + $request = new Request([], [...$body, '_csrf_token' => $csrfToken]); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + + /** + * @return mixed[] + */ + private function flashes(Request $request, string $type): array + { + $session = $request->getSession(); + self::assertInstanceOf(Session::class, $session); + + return $session->getFlashBag()->get($type); + } + + private function authorizedPayment(string $currency = 'EUR'): Payment + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([PayPlugGatewayFactory::HOSTED_FIELDS => true, PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_1']); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $order = new class() extends Order { + public function getId(): int + { + return 7; + } + }; + + $payment = new class() extends Payment { + public function getId(): int + { + return 42; + } + }; + $payment->setOrder($order); + $payment->setMethod($method); + $payment->setAmount(1000); + $payment->setCurrencyCode($currency); + $payment->setState(PaymentInterface::STATE_AUTHORIZED); + $payment->setDetails(AuthorizationDetails::open( + ['hosted_fields_payment_id' => 'pay_1'], + new PaymentOutput(201, '{}', null, null, null, '2026-09-30T10:00:00+00:00', 1000), + 1000, + )); + + return $payment; + } +} diff --git a/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php new file mode 100644 index 00000000..2b49a754 --- /dev/null +++ b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php @@ -0,0 +1,241 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api-qa.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->factory = new PayPlugApiClientFactory($this->gatewayConfigRepository, $this->cache, $tokenManager); + } + + // ------------------------------------------------------------------------- + // create() / createForPaymentMethod() — happy path, token freshly fetched + // ------------------------------------------------------------------------- + + public function testCreateForPaymentMethod_withValidClientCredentials_returnsApiClient(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: false)); + + $this->tokenCache->method('get')->willReturn(null); // cache miss + $this->oauthHttpClient->method('post')->willReturn([ + 'status' => 200, + 'body' => json_encode(['access_token' => 'fresh-jwt', 'expires_in' => 300, 'token_type' => 'Bearer']), + ]); + + $client = $this->factory->createForPaymentMethod($paymentMethod); + + self::assertInstanceOf(PayPlugApiClientInterface::class, $client); + } + + public function testCreate_withNoGatewayConfigFound_throwsLogicException(): void + { + $this->gatewayConfigRepository->method('findOneBy')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->factory->create('payplug'); + } + + // ------------------------------------------------------------------------- + // getTokenForGatewayConfig() — missing client config + // ------------------------------------------------------------------------- + + public function testCreateForPaymentMethod_withNoClientConfigForCurrentMode_throwsGatewayConfigurationException(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['live' => true]); // no 'live_client' key + $gatewayConfig->method('getFactoryName')->willReturn('payplug'); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(GatewayConfigurationException::class); + $this->expectExceptionMessage('No client config found for payplug'); + + $this->factory->createForPaymentMethod($paymentMethod); + } + + // ------------------------------------------------------------------------- + // getTokenForGatewayConfig() — client config present but missing client_id/client_secret + // ------------------------------------------------------------------------- + + /** + * A present-but-incomplete client config (e.g. `client_secret` missing) must be rejected + * before any HTTP call is made — otherwise it reaches the token endpoint with an empty + * credential and a genuine misconfiguration gets reported as a connectivity failure instead. + */ + public function testCreateForPaymentMethod_withEmptyClientSecret_throwsGatewayConfigurationExceptionWithoutCallingTokenEndpoint(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => false, + 'test_client' => ['client_id' => 'client_test'], // no 'client_secret' key + ]); + $gatewayConfig->method('getFactoryName')->willReturn('payplug'); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->oauthHttpClient->expects(self::never())->method('post'); + + $this->expectException(GatewayConfigurationException::class); + $this->expectExceptionMessage('No client config found for payplug'); + + $this->factory->createForPaymentMethod($paymentMethod); + } + + // ------------------------------------------------------------------------- + // getTokenForGatewayConfig() — token endpoint failure wrapped as GatewayConfigurationException + // ------------------------------------------------------------------------- + + /** + * TokenManager -> OAuth2Client throws ApiException on a non-2xx response; the factory must + * catch it and rethrow as GatewayConfigurationException (never leak the vendor exception type). + */ + public function testCreateForPaymentMethod_whenTokenEndpointRejectsCredentials_wrapsFailureAsGatewayConfigurationException(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: true)); + + $this->tokenCache->method('get')->willReturn(null); + $this->oauthHttpClient->method('post')->willReturn(['status' => 401, 'body' => '{"error":"invalid_client"}']); + + $this->expectException(GatewayConfigurationException::class); + $this->expectExceptionMessage('Unable to connect to PayPlug API. Please check your credentials in the PayPlug plugin configuration.'); + + $this->factory->createForPaymentMethod($paymentMethod); + } + + // ------------------------------------------------------------------------- + // getTokenForGatewayConfig() — a cached token is reused, no HTTP call made + // ------------------------------------------------------------------------- + + public function testCreateForPaymentMethod_withCachedToken_doesNotCallTheTokenEndpoint(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($this->buildGatewayConfig(isLive: false)); + + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->oauthHttpClient->expects(self::never())->method('post'); + + $this->factory->createForPaymentMethod($paymentMethod); + } + + // ------------------------------------------------------------------------- + // createForPaymentMethod() — credentials are scoped to the payment method, not the factory name + // ------------------------------------------------------------------------- + + /** + * Since PRE-3628 several enabled gateway configs may share a factory name — one per channel. + * `findOneBy(['factoryName' => ...])` then resolves to an arbitrary one of them, so a client + * built that way can sign a request for channel A with channel B's account credentials. + * createForPaymentMethod() must read the credentials off the payment method's own gateway + * config and never consult the repository; routing it back through that lookup is the + * production change that makes this test fail. + */ + public function testCreateForPaymentMethod_withTwoChannelsSharingAFactoryName_usesEachChannelsOwnCredentials(): void + { + $frConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_fr', clientSecret: 'secret_fr'); + $deConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_de', clientSecret: 'secret_de'); + + // Both rows are enabled and carry factoryName 'payplug', so Doctrine is free to return + // either one; the FR row stands in for "whichever one it picked". + $this->gatewayConfigRepository->method('findOneBy')->willReturn($frConfig); + + $this->tokenCache->method('get')->willReturn(null); // cache miss for both client ids + + /** @var list $sentCredentials */ + $sentCredentials = []; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials[] = $headers['Authorization']; + + return [ + 'status' => 200, + 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer']), + ]; + }, + ); + + $this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('FR', $frConfig)); + $this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('DE', $deConfig)); + + self::assertSame([ + 'Basic ' . base64_encode('client_fr:secret_fr'), + 'Basic ' . base64_encode('client_de:secret_de'), + ], $sentCredentials); + } + + private function buildPaymentMethodOnChannel( + string $channelCode, + GatewayConfigInterface $gatewayConfig, + ): PaymentMethodInterface&MockObject { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $paymentMethod = $this->createMock(CorePaymentMethodInterface::class); + $paymentMethod->method('isEnabled')->willReturn(true); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } + + private function buildGatewayConfig( + bool $isLive, + string $clientId = 'client', + string $clientSecret = 'secret', + ): GatewayConfigInterface&MockObject { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => $isLive, + 'live_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret], + 'test_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret], + ]); + $gatewayConfig->method('getFactoryName')->willReturn('payplug'); + + return $gatewayConfig; + } +} diff --git a/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php new file mode 100644 index 00000000..6cf0479d --- /dev/null +++ b/tests/PHPUnit/Auth/GatewayConnectionRevokerTest.php @@ -0,0 +1,174 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->tokenCache = new SyliusTokenCache(new ArrayAdapter()); + $this->revoker = new GatewayConnectionRevoker($this->entityManager, $this->tokenCache); + } + + public function testRevoke_removesTheOAuthCredentialsFromTheGatewayConfig(): void + { + $paymentMethod = $this->paymentMethod(); + + $this->revoker->revoke($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertArrayNotHasKey('live_client', $config); + self::assertArrayNotHasKey('test_client', $config); + self::assertArrayNotHasKey('account_email', $config); + } + + public function testRevoke_keepsTheMerchantSettingsThatAreNotCredentials(): void + { + $paymentMethod = $this->paymentMethod(config: [ + 'live' => true, + PayPlugGatewayFactory::ONE_CLICK => true, + 'fees_for' => 'merchant', + ]); + + $this->revoker->revoke($paymentMethod); + + $config = $paymentMethod->getGatewayConfig()?->getConfig() ?? []; + self::assertTrue($config['live']); + self::assertTrue($config[PayPlugGatewayFactory::ONE_CLICK]); + self::assertSame('merchant', $config['fees_for']); + } + + public function testRevoke_disablesThePaymentMethod(): void + { + $paymentMethod = $this->paymentMethod(); + $paymentMethod->enable(); + + $this->revoker->revoke($paymentMethod); + + self::assertFalse($paymentMethod->isEnabled()); + } + + public function testRevoke_dropsTheCachedTokenOfBothEnvironments(): void + { + $this->tokenCache->set('upc_oauth_token:client_live', 'live-access-token', 3600); + $this->tokenCache->set('upc_oauth_token:client_test', 'test-access-token', 3600); + + $this->revoker->revoke($this->paymentMethod()); + + self::assertNull($this->tokenCache->get('upc_oauth_token:client_live')); + self::assertNull($this->tokenCache->get('upc_oauth_token:client_test')); + } + + public function testRevoke_leavesAnotherGatewaysCachedTokenAlone(): void + { + $this->tokenCache->set('upc_oauth_token:client_of_another_channel', 'other-access-token', 3600); + + $this->revoker->revoke($this->paymentMethod()); + + self::assertSame('other-access-token', $this->tokenCache->get('upc_oauth_token:client_of_another_channel')); + } + + public function testRevoke_hostedFieldsCbGateway_alsoClearsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod(config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ]); + + $this->revoker->revoke($paymentMethod); + + self::assertArrayNotHasKey(PayPlugGatewayFactory::HF_IDENTIFIER, $paymentMethod->getGatewayConfig()?->getConfig() ?? []); + } + + public function testRevoke_cbGatewayWithoutHostedFields_keepsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod(config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => false, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ]); + + $this->revoker->revoke($paymentMethod); + + self::assertSame('acc_123', ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null); + } + + public function testRevoke_nonCbGateway_keepsTheHostedFieldsAccountId(): void + { + $paymentMethod = $this->paymentMethod( + factoryName: OneyGatewayFactory::FACTORY_NAME, + config: [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acc_123', + ], + ); + + $this->revoker->revoke($paymentMethod); + + self::assertSame('acc_123', ($paymentMethod->getGatewayConfig()?->getConfig() ?? [])[PayPlugGatewayFactory::HF_IDENTIFIER] ?? null); + } + + public function testRevoke_persistsTheChangeOnce(): void + { + $this->entityManager->expects(self::once())->method('flush'); + + $this->revoker->revoke($this->paymentMethod()); + } + + public function testRevoke_paymentMethodWithoutGatewayConfig_throws(): void + { + $paymentMethod = new PaymentMethod(); + + $this->expectException(\LogicException::class); + + $this->revoker->revoke($paymentMethod); + } + + /** + * @param array $config merged over the credentials every connected gateway holds + */ + private function paymentMethod( + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + array $config = [], + ): PaymentMethodInterface { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName($factoryName); + $gatewayConfig->setConfig([ + 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'], + 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'], + 'account_email' => 'merchant@example.com', + ...$config, + ]); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php b/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php new file mode 100644 index 00000000..7ba94f8c --- /dev/null +++ b/tests/PHPUnit/Auth/IdTokenEmailExtractorTest.php @@ -0,0 +1,136 @@ +extractor = new IdTokenEmailExtractor(); + } + + /** + * Builds a JWT-shaped string whose payload segment is the given claims. The signature is + * deliberately a fixed placeholder: the extractor never verifies it (see the class docblock), + * so a real one would prove nothing that this doesn't. + * + * @param array $claims + */ + private function idTokenWithClaims(array $claims): string + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + return $encode('{"alg":"RS256","typ":"JWT"}') . '.' . $encode((string) json_encode($claims)) . '.c2lnbmF0dXJl'; + } + + // ------------------------------------------------------------------------- + // Happy path + // ------------------------------------------------------------------------- + + public function testExtract_returnsTheEmailClaim(): void + { + $idToken = $this->idTokenWithClaims(['sub' => 'user_1', 'email' => 'merchant@example.com']); + + self::assertSame('merchant@example.com', $this->extractor->extract($idToken)); + } + + /** + * The payload is base64url-encoded, not plain base64: real PayPlug id tokens routinely contain + * `-` and `_` where standard base64 would emit `+` and `/`, and are stripped of `=` padding. + * Decoding one with plain base64_decode() would corrupt the JSON. + */ + public function testExtract_decodesABase64UrlPayloadContainingUrlUnsafeCharacters(): void + { + $claims = ['email' => 'merchant@example.com', 'nonce' => '>>>???~~~']; + $idToken = $this->idTokenWithClaims($claims); + + self::assertStringNotContainsString('+', explode('.', $idToken)[1]); + self::assertStringNotContainsString('/', explode('.', $idToken)[1]); + self::assertSame('merchant@example.com', $this->extractor->extract($idToken)); + } + + // ------------------------------------------------------------------------- + // Absent / malformed input — every branch degrades to null, never throws + // ------------------------------------------------------------------------- + + public function testExtract_returnsNullWhenTheTokenIsNull(): void + { + self::assertNull($this->extractor->extract(null)); + } + + public function testExtract_returnsNullWhenTheTokenIsEmpty(): void + { + self::assertNull($this->extractor->extract('')); + } + + public function testExtract_returnsNullWhenTheTokenIsNotThreeSegments(): void + { + self::assertNull($this->extractor->extract('header.payload')); + } + + /** + * A payload segment whose length is ≡ 1 (mod 4) cannot be valid base64 — no encoder ever emits + * one — so the padding loop produces a string that base64_decode(..., true) rejects outright. + * The decode therefore short-circuits before json_decode() is ever reached. + */ + public function testExtract_returnsNullWhenThePayloadIsNotDecodableBase64(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertSame(1, \strlen('abcde') % 4); + self::assertNull($this->extractor->extract($encode('{}') . '.abcde.sig')); + } + + public function testExtract_returnsNullWhenThePayloadIsNotValidJson(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertNull($this->extractor->extract($encode('{}') . '.' . $encode('not json') . '.sig')); + } + + public function testExtract_returnsNullWhenThePayloadIsAJsonScalarRatherThanAnObject(): void + { + $encode = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + self::assertNull($this->extractor->extract($encode('{}') . '.' . $encode('"just-a-string"') . '.sig')); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsAbsent(): void + { + $idToken = $this->idTokenWithClaims(['sub' => 'user_1', 'name' => 'A Merchant']); + + self::assertNull($this->extractor->extract($idToken)); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsNotAString(): void + { + $idToken = $this->idTokenWithClaims(['email' => ['merchant@example.com']]); + + self::assertNull($this->extractor->extract($idToken)); + } + + /** + * The claim is rendered verbatim on an admin screen, so a value that isn't an address is + * treated as no value at all rather than echoed back. + */ + public function testExtract_returnsNullWhenTheEmailClaimIsNotAnAddress(): void + { + $idToken = $this->idTokenWithClaims(['email' => 'not-an-email']); + + self::assertNull($this->extractor->extract($idToken)); + } + + public function testExtract_returnsNullWhenTheEmailClaimIsEmpty(): void + { + $idToken = $this->idTokenWithClaims(['email' => '']); + + self::assertNull($this->extractor->extract($idToken)); + } +} diff --git a/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php new file mode 100644 index 00000000..eede4155 --- /dev/null +++ b/tests/PHPUnit/Auth/SyliusOAuthHttpClientTest.php @@ -0,0 +1,128 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->adapter = new SyliusOAuthHttpClient($this->httpClient); + } + + // ------------------------------------------------------------------------- + // post() — delegates to HttpClientInterface with form-encoded body + // ------------------------------------------------------------------------- + + /** + * Verifies the form params are sent as a URL-encoded body (not a raw array), and the given + * headers are passed through unchanged. + */ + public function testPost_sendsFormEncodedBodyAndHeaders(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->with(false)->willReturn('{"access_token":"jwt"}'); + + $this->httpClient->expects(self::once()) + ->method('request') + ->with( + 'POST', + 'https://api-qa.payplug.com/oauth2/token', + [ + 'body' => 'grant_type=authorization_code&client_id=client_abc', + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + ], + ) + ->willReturn($response) + ; + + $result = $this->adapter->post( + 'https://api-qa.payplug.com/oauth2/token', + ['grant_type' => 'authorization_code', 'client_id' => 'client_abc'], + ['Content-Type' => 'application/x-www-form-urlencoded'], + ); + + self::assertSame(['status' => 200, 'body' => '{"access_token":"jwt"}'], $result); + } + + // ------------------------------------------------------------------------- + // post() — non-2xx status does not throw (caller decides how to react) + // ------------------------------------------------------------------------- + + /** + * getContent(false) is used specifically so a 4xx/5xx response body is still returned + * instead of throwing — OAuth2Client itself is responsible for checking the status. + */ + public function testPost_onNon2xxStatus_returnsStatusAndBodyWithoutThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(401); + $response->method('getContent')->with(false)->willReturn('{"error":"invalid_client"}'); + + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']); + + self::assertSame(['status' => 401, 'body' => '{"error":"invalid_client"}'], $result); + } + + // ------------------------------------------------------------------------- + // post() — default empty headers array is accepted + // ------------------------------------------------------------------------- + + public function testPost_withNoHeadersArgument_defaultsToEmptyHeaders(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn('{}'); + + $this->httpClient->expects(self::once()) + ->method('request') + ->with(self::anything(), self::anything(), self::callback( + static fn (array $options): bool => [] === $options['headers'], + )) + ->willReturn($response) + ; + + $this->adapter->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']); + } + + // ------------------------------------------------------------------------- + // post() — transport-level failure (network error) does not throw + // ------------------------------------------------------------------------- + + /** + * getStatusCode()/getContent() throw TransportExceptionInterface on a genuine network error + * (DNS, timeout, connection reset) regardless of the `false` passed to getContent() — that + * flag only suppresses HTTP status exceptions, not transport ones. This must be caught here + * and turned into a status the caller can react to (0, i.e. never a valid HTTP status), + * instead of leaking an uncaught exception into OAuth2Client/TokenManager, which only know + * how to translate a malformed HTTP response into ApiException, not a transport failure. + */ + public function testPost_onTransportFailure_returnsZeroStatusInsteadOfThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willThrowException(new TransportException('Could not resolve host')); + + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->post('https://api-qa.payplug.com/oauth2/token', ['grant_type' => 'client_credentials']); + + self::assertSame(0, $result['status']); + self::assertSame('Could not resolve host', $result['body']); + } +} diff --git a/tests/PHPUnit/Auth/SyliusTokenCacheTest.php b/tests/PHPUnit/Auth/SyliusTokenCacheTest.php new file mode 100644 index 00000000..71331456 --- /dev/null +++ b/tests/PHPUnit/Auth/SyliusTokenCacheTest.php @@ -0,0 +1,115 @@ +pool = $this->createMock(CacheItemPoolInterface::class); + $this->cache = new SyliusTokenCache($this->pool); + } + + // ------------------------------------------------------------------------- + // get() — cache hit / miss + // ------------------------------------------------------------------------- + + public function testGet_onCacheHit_returnsTheStoredValue(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn('cached-jwt'); + + $this->pool->method('getItem')->with('upc_oauth_token_client_abc')->willReturn($item); + + self::assertSame('cached-jwt', $this->cache->get('upc_oauth_token:client_abc')); + } + + public function testGet_onCacheMiss_returnsNull(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $this->pool->method('getItem')->willReturn($item); + + self::assertNull($this->cache->get('upc_oauth_token:client_abc')); + } + + // ------------------------------------------------------------------------- + // set() — stores the value with the given TTL + // ------------------------------------------------------------------------- + + public function testSet_storesValueAndTtlThenSavesTheItem(): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('set')->with('fresh-jwt'); + $item->expects(self::once())->method('expiresAfter')->with(240); + + $this->pool->method('getItem')->with('upc_oauth_token_client_abc')->willReturn($item); + $this->pool->expects(self::once())->method('save')->with($item); + + $this->cache->set('upc_oauth_token:client_abc', 'fresh-jwt', 240); + } + + // ------------------------------------------------------------------------- + // delete() + // ------------------------------------------------------------------------- + + public function testDelete_removesTheSanitizedKeyFromThePool(): void + { + $this->pool->expects(self::once())->method('deleteItem')->with('upc_oauth_token_client_abc'); + + $this->cache->delete('upc_oauth_token:client_abc'); + } + + // ------------------------------------------------------------------------- + // Key sanitization — PSR-6 reserved characters must never reach the pool + // ------------------------------------------------------------------------- + + /** + * Symfony's cache component rejects keys containing any of "{}()/\@:" with an + * InvalidArgumentException. TokenManager's own key format ("upc_oauth_token:{clientId}") + * contains a colon, so this is a real, not hypothetical, input. + * + * @dataProvider reservedCharacterKeys + */ + public function testSanitizeKey_replacesEveryPsr6ReservedCharacter( + string $rawKey, + string $expectedSanitizedKey, + ): void + { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + + $this->pool->expects(self::once())->method('getItem')->with($expectedSanitizedKey)->willReturn($item); + + $this->cache->get($rawKey); + } + + /** + * @return iterable + */ + public static function reservedCharacterKeys(): iterable + { + yield 'colon (TokenManager\'s real format)' => ['upc_oauth_token:client_abc', 'upc_oauth_token_client_abc']; + yield 'curly braces' => ['a{b}c', 'a_b_c']; + yield 'parentheses' => ['a(b)c', 'a_b_c']; + yield 'slash' => ['a/b', 'a_b']; + yield 'backslash' => ['a\\b', 'a_b']; + yield 'at sign' => ['a@b', 'a_b']; + yield 'all reserved characters combined' => ['{}()/\\@:', '________']; + yield 'no reserved characters' => ['plain_key_123', 'plain_key_123']; + } +} diff --git a/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php b/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php new file mode 100644 index 00000000..0be806d2 --- /dev/null +++ b/tests/PHPUnit/Checker/GatewayChannelConflictCheckerTest.php @@ -0,0 +1,291 @@ +paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + $this->checker = new GatewayChannelConflictChecker($this->paymentMethodRepository); + } + + public function testFindConflicts_enabledRivalSharesChannel_isReported(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]) + ; + + $conflicts = $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertCount(1, $conflicts); + self::assertSame('WEB_FR', $conflicts[0]['channel']->getCode()); + self::assertSame('CB 1', $conflicts[0]['paymentMethod']->getName()); + } + + public function testFindConflicts_channelSetsAreDisjoint_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, ['WEB_IT'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_twoSharedChannels_bothAreReported(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR', 'WEB_IT', 'WEB_BE']); + $rival = $this->paymentMethod(7, true, ['WEB_IT', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $conflicts = $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertCount(2, $conflicts); + self::assertSame( + ['WEB_IT', 'WEB_BE'], + array_map(static fn (array $conflict): ?string => $conflict['channel']->getCode(), $conflicts), + ); + } + + public function testFindConflicts_subjectIsDisabled_isAllowedWithoutQuerying(): void + { + $subject = $this->paymentMethod(null, false, ['WEB_FR']); + + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_subjectHasNoChannel_isAllowedWithoutQuerying(): void + { + $subject = $this->paymentMethod(null, true, []); + + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Editing an existing gateway must not make it conflict with itself. + */ + public function testFindConflicts_rivalIsTheSubjectItself_isAllowed(): void + { + $subject = $this->paymentMethod(7, true, ['WEB_FR']); + $itself = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$itself]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * The repository already filters on `enabled`, but the rule is re-asserted here so the whole + * rule is expressed — and testable — in one place. + */ + public function testFindConflicts_rivalIsDisabled_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, false, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + public function testFindConflicts_rivalHasNoChannel_isAllowed(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + $rival = $this->paymentMethod(7, true, [], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findConflicts($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Different factory types never conflict: the lookup is scoped to the factory being saved. + */ + public function testFindConflicts_queriesOnlyTheGivenFactory(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(OneyGatewayFactory::FACTORY_NAME) + ->willReturn([]) + ; + + self::assertSame([], $this->checker->findConflicts($subject, OneyGatewayFactory::FACTORY_NAME)); + } + + public function testFindClaimedChannels_enabledRivalChannels_areClaimed(): void + { + $subject = $this->paymentMethod(null, true, []); + $rival = $this->paymentMethod(7, true, ['WEB_FR', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]) + ; + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame(['WEB_FR', 'WEB_BE'], array_keys($claimed)); + self::assertSame('CB 1', $claimed['WEB_FR']->getName()); + self::assertSame('CB 1', $claimed['WEB_BE']->getName()); + } + + public function testFindClaimedChannels_severalRivals_areAllReported(): void + { + $subject = $this->paymentMethod(null, true, []); + $firstRival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + $secondRival = $this->paymentMethod(8, true, ['WEB_IT'], 'CB 2'); + + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->willReturn([$firstRival, $secondRival]) + ; + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame('CB 1', $claimed['WEB_FR']->getName()); + self::assertSame('CB 2', $claimed['WEB_IT']->getName()); + } + + /** + * The channels the edited gateway already holds must stay selectable: a disabled checkbox is + * not submitted by the browser, so disabling a *checked* one would silently drop the channel + * on save. Leaving it selectable lets the POST_SUBMIT rule report the conflict instead. + */ + public function testFindClaimedChannels_rivalIsTheSubjectItself_isNotClaimed(): void + { + $subject = $this->paymentMethod(7, true, ['WEB_FR']); + $itself = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$itself]); + + self::assertSame([], $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Same guarantee as above, for the case the id comparison cannot catch: a *different* enabled + * payment method already holds a channel the subject also holds. Reachable through the admin + * alone — create A disabled on a channel (a disabled subject is exempt from `findConflicts()`), + * then create B enabled on it (A, being disabled, is neither a claim nor a conflict). Editing A + * afterwards must not disable its own checked box, or saving A silently drops the channel. + * + * The rival's other channels stay claimed. + */ + public function testFindClaimedChannels_channelTheSubjectAlreadyHolds_isNotClaimed(): void + { + $subject = $this->paymentMethod(5, true, ['WEB_FR']); + $rival = $this->paymentMethod(6, true, ['WEB_FR', 'WEB_BE'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $claimed = $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME); + + self::assertSame(['WEB_BE'], array_keys($claimed)); + self::assertSame('CB 1', $claimed['WEB_BE']->getName()); + } + + public function testFindClaimedChannels_rivalIsDisabled_isNotClaimed(): void + { + $subject = $this->paymentMethod(null, true, []); + $rival = $this->paymentMethod(7, false, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame([], $this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)); + } + + /** + * Unlike `findConflicts()`, the picker is rendered before the admin has decided anything: the + * claimed set must not depend on the subject's own `enabled` flag or channel selection. + */ + public function testFindClaimedChannels_subjectIsDisabled_stillReportsClaims(): void + { + $subject = $this->paymentMethod(null, false, []); + $rival = $this->paymentMethod(7, true, ['WEB_FR'], 'CB 1'); + + $this->paymentMethodRepository->method('findEnabledByGatewayName')->willReturn([$rival]); + + self::assertSame( + ['WEB_FR'], + array_keys($this->checker->findClaimedChannels($subject, PayPlugGatewayFactory::FACTORY_NAME)), + ); + } + + public function testFindClaimedChannels_queriesOnlyTheGivenFactory(): void + { + $subject = $this->paymentMethod(null, true, ['WEB_FR']); + + $this->paymentMethodRepository + ->expects(self::once()) + ->method('findEnabledByGatewayName') + ->with(OneyGatewayFactory::FACTORY_NAME) + ->willReturn([]) + ; + + self::assertSame([], $this->checker->findClaimedChannels($subject, OneyGatewayFactory::FACTORY_NAME)); + } + + /** + * @param list $channelCodes + * + * @return PaymentMethodInterface&MockObject + */ + private function paymentMethod( + ?int $id, + bool $enabled, + array $channelCodes, + string $name = 'CB', + ): PaymentMethodInterface { + $channels = []; + foreach ($channelCodes as $channelCode) { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + $channels[] = $channel; + } + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn($id); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getName')->willReturn($name); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection($channels)); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php new file mode 100644 index 00000000..fd7853d9 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php @@ -0,0 +1,428 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $sessionData = []; + $this->session->method('set')->willReturnCallback(static function (string $key, mixed $value) use (&$sessionData): void { + $sessionData[$key] = $value; + }); + $this->session->method('get')->willReturnCallback(static function (string $key, mixed $default = null) use (&$sessionData): mixed { + return $sessionData[$key] ?? $default; + }); + + $request = new \Symfony\Component\HttpFoundation\Request(); + $request->setSession($this->session); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); + + $this->handler = new CaptureAliasPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->unifiedApiPaymentCreator, + new SelectedCardResolver($this->requestStack, $this->payplugCardRepository), + new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack), + new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack), + ); + } + + /** + * @param CustomerInterface&MockObject|null $cardCustomer customer the selected Card belongs + * to; defaults to the paying order's + * own customer (the happy path) — + * pass a different mock to exercise + * the ownership-mismatch guard + * @param PaymentMethodInterface&MockObject|null $cardPaymentMethod payment method the selected + * Card was saved under; defaults to + * the payment's own method (the happy + * path) — pass a different mock to + * exercise the account-mismatch guard + */ + private function paymentRequestWithSelectedCard( + ?Card $card, + ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'], + ?CustomerInterface $cardCustomer = null, + ?AddressInterface $billingAddress = null, + ?PaymentMethodInterface $cardPaymentMethod = null, + ?string $customerEmail = 'customer@example.com', + ): PaymentRequestInterface&MockObject + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $gatewayConfig) { + $config = $this->createMock(GatewayConfigInterface::class); + $config->method('getConfig')->willReturn($gatewayConfig); + $method->method('getGatewayConfig')->willReturn($config); + } + + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn($customerEmail); + + $card?->setCustomer($cardCustomer ?? $customer); + $card?->setPaymentMethod($cardPaymentMethod ?? $method); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getBillingAddress')->willReturn($billingAddress); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn([]); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + private function savedCard(): Card + { + return (new Card())->setExternalId('alias_existing_1')->setBrand('VISA')->setLast4('4242') + ->setExpirationMonth(12)->setExpirationYear(2030)->setCountryCode('FR')->setIsLive(false); + } + + public function testInvoke_withNoCardSelected_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard(null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withGatewayConfigMissingAccountId_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), gatewayConfig: null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARedirect(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onApiException_failsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom')); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withCardBelongingToAnotherCustomer_failsThePaymentRequest(): void + { + $anotherCustomer = $this->createMock(CustomerInterface::class); + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardCustomer: $anotherCustomer); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withCardBelongingToAnotherPaymentMethod_failsThePaymentRequest(): void + { + $anotherMethod = $this->createMock(PaymentMethodInterface::class); + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), cardPaymentMethod: $anotherMethod); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withNoCustomerEmail_failsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard(), customerEmail: null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_existing_1')); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, 'alias_existing_1')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_url' => 'https://example.com/3ds']); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '3ds', 'alias_existing_1')); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_html' => '
3ds
']); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl); + self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withABillingAddress_sendsItsFullNameAsThePaymentMethodDetails(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $this->paymentRequestWithSelectedCard($this->savedCard(), billingAddress: $billingAddress); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_withNoBillingAddress_leavesPaymentMethodNull(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (PaymentDto $dto): bool { + self::assertNull($dto->paymentMethod); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, 'alias_existing_1')); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onSuccess_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_existing_1')); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) && + 'op_1' === ($details['hosted_fields_operation_id'] ?? null) && + 'alias_existing_1' === ($details['alias_id'] ?? null))); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_sendsTheSavedCardAliasOnlyThroughTheDtoAndCompletesWhenOutputCarriesNoAliasId(): void + { + $card = $this->savedCard(); + $paymentRequest = $this->paymentRequestWithSelectedCard($card); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(static function (PaymentDto $dto) use ($card): bool { + self::assertSame($card->getExternalId(), $dto->aliasId); + foreach (['id', 'storedId'] as $reserved) { + self::assertArrayNotHasKey($reserved, $dto->paymentMethod ?? []); + } + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'alias_existing_1' === ($details['alias_id'] ?? null))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_whenResponseHasNoPaymentId_stillCompletesAndLogsAWarning(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"execCode":"0000","operationIds":["op_1"]}', null, null, null)); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => !isset($details['hosted_fields_payment_id']) && + 'op_1' === ($details['hosted_fields_operation_id'] ?? null))); + $this->logger->expects(self::once())->method('warning') + ->with(self::stringContains('carried no payment id'), self::anything()); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_whenResponseHasAPaymentId_logsNoWarning(): void + { + $this->paymentRequestWithSelectedCard($this->savedCard()); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, null)); + + $this->logger->expects(self::never())->method('warning'); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } + + public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithSelectedCard($this->savedCard()); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, 'alias_existing_1')); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === ($details['hosted_fields_payment_id'] ?? null) && + 'op_1' === ($details['hosted_fields_operation_id'] ?? null))); + + $this->handler->__invoke(new CaptureAliasPaymentRequest(null)); + } +} diff --git a/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php new file mode 100644 index 00000000..09f20306 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php @@ -0,0 +1,673 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->unifiedApiPaymentCreator = $this->createMock(UnifiedApiPaymentCreatorInterface::class); + $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->requestStack = $this->createMock(RequestStack::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + + $this->handler = new CaptureHostedPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->unifiedApiPaymentCreator, + $this->operationStatusFetcher, + new PaymentCaptureContextBuilder($this->urlGenerator, $this->afterPayUrlProvider, new OrderAddressDtoCreator(), $this->requestStack), + new PaymentCaptureOutcomeApplier($this->logger, $this->stateMachine, $this->orderStateMutator, $this->requestStack), + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + $this->logger, + ); + } + + private function paymentRequestWithPayment( + array $details, + int $amount = 1000, + string $currency = 'EUR', + ?array $gatewayConfig = ['hfIdentifier' => 'acct_123'], + ?AddressInterface $billingAddress = null, + ): PaymentRequestInterface&MockObject + { + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $gatewayConfig) { + $config = $this->createMock(GatewayConfigInterface::class); + $config->method('getConfig')->willReturn($gatewayConfig); + $method->method('getGatewayConfig')->willReturn($config); + } + + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('customer@example.com'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getBillingAddress')->willReturn($billingAddress); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn($details); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn($amount); + $payment->method('getCurrencyCode')->willReturn($currency); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + public function testInvoke_onDirectSuccess_completesThePaymentRequestWithoutARedirect(): void + { + $paymentRequest = $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => !isset($data['redirect_url']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureHostedPaymentRequest($paymentRequest->getId())); + } + + public function testInvoke_whenNoHostedFieldsTokenStored_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithPayment([]); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => isset($data['error']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenGatewayConfigIsMissingAccountOrSubmerchantId_failsThePaymentRequestInsteadOfCrashing(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc'], gatewayConfig: null); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => isset($data['error']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onApiException_failsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment')->willThrowException(new ApiException('boom')); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenCustomerEmailIsMissing_failsThePaymentRequestInsteadOfCallingUnifiedApiPaymentCreator(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getEmail')->willReturn(null); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn(['hosted_fields_token' => 'hf_token_abc']); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $this->unifiedApiPaymentCreator->expects(self::never())->method('createPayment'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(self::callback(static fn (array $data): bool => isset($data['error']))); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccessWithoutExecCode_neverAppliesOrderStateMutator(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccessWithSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onDirectSuccessWithFailureExecCode_appliesFailedOutcomeToOrderStateMutator(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"9999"}', null, null, null)); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::FAILED); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onRedirectOutcome_neverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null)); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_url' => 'https://example.com/3ds']); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onPending3ds_storesTheUnifiedApiPaymentAndOperationIdsOnThePaymentDetails(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(200, '{"id":"pay_1","execCode":"0001","operationIds":["op_1"]}', 'https://example.com/3ds', null, null)); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => 'pay_1' === $details['hosted_fields_payment_id'] && + 'op_1' === $details['hosted_fields_operation_id'])); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenResponseBodyHasNoId_neverStoresAHostedFieldsPaymentOrOperationId(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + $payment = $paymentRequest->getPayment(); + + $this->unifiedApiPaymentCreator->method('createPayment')->willReturn(new PaymentOutput(201, '{}', null, null, null)); + + $payment->expects(self::once())->method('setDetails') + ->with(self::callback(static fn (array $details): bool => !isset($details['hosted_fields_payment_id']) && + !isset($details['hosted_fields_operation_id']))); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenResponseHasNoPaymentId_stillCompletesAndLogsAWarning(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"execCode":"0000","operationIds":["op_1"]}', null, null, null)); + + $this->logger->expects(self::once())->method('warning') + ->with(self::stringContains('carried no payment id'), self::anything()); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenResponseHasAPaymentId_logsNoWarning(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, null)); + + $this->logger->expects(self::never())->method('warning'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_onRedirectHtmlOutcome_storesRedirectHtmlAndNeverAppliesOrderStateMutator(): void + { + $paymentRequest = $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '
3ds
', null)); + + $this->orderStateMutator->expects(self::never())->method('apply'); + $paymentRequest->expects(self::once())->method('setResponseData') + ->with(['redirect_html' => '
3ds
']); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_setsSuccessAndCancelUrlOnTheUnifiedApiRequest(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc']); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertSame('https://shop.test/order/00000042/pay', $dto->common->successUrl); + self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $dto->common->cancelUrl); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_withABillingAddress_sendsItsFullNameAlongsideSelectedBrand(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $this->paymentRequestWithPayment( + ['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA'], + billingAddress: $billingAddress, + ); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertSame('John Doe', $dto->paymentMethod['details']['fullName'] ?? null); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_withNoBillingAddress_omitsFullNameButStillSendsSelectedBrand(): void + { + $this->paymentRequestWithPayment(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_selected_brand' => 'VISA']); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + self::assertArrayNotHasKey('fullName', $dto->paymentMethod['details'] ?? []); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedWithNoFullNameOrBrandAvailable_omitsSaveFutureUsageInsteadOfFailingThePayment(): void + { + $paymentRequest = $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => true, + ]); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertNull($dto->paymentMethod); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1"}', null, null, null)); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedAndAliasReturned_persistsANewCard(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + + $paymentRequest = $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ], billingAddress: $billingAddress); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(function (HostedFieldDto $dto): bool { + self::assertSame('ONE_CLICK', $dto->recurringMode); + self::assertIsArray($dto->paymentMethod); + self::assertTrue($dto->paymentMethod['saveFutureUsage'] ?? false); + self::assertSame('VISA', $dto->paymentMethod['details']['selectedBrand'] ?? null); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1')); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('alias_new_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + public function testInvoke_neverSetsHfTokenIdOrStoredIdInPaymentMethod(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('John Doe'); + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + ], billingAddress: $billingAddress); + + $this->unifiedApiPaymentCreator->expects(self::once())->method('createPayment') + ->with(self::callback(static function (HostedFieldDto $dto): bool { + self::assertIsArray($dto->paymentMethod); + foreach (['hfToken', 'id', 'storedId'] as $reserved) { + self::assertArrayNotHasKey($reserved, $dto->paymentMethod); + } + self::assertSame('hf_token_abc', $dto->hfToken); + + return true; + })) + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1')); + $this->payplugCardFactory->method('createNew')->willReturn(new Card()); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedAndUnifiedApiOperationIdAvailable_enrichesTheCardWithExpirationFetchedFromTheUnifiedApi(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_country' => 'BE', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + // Real shape confirmed against a staging operation response: card metadata lives under + // paymentMethod.card (network, a masked code6x4 PAN standing in for a dedicated last4 + // field) and paymentMethod.details (selectedBrand, validityDate in "YYYY-MM" form). No + // country field exists anywhere on that response. + $this->operationStatusFetcher->expects(self::once())->method('getOperation') + ->with('op_1') + ->willReturn(['status' => 200, 'body' => json_encode([ + 'paymentMethod' => [ + 'id' => 'alias_new_1', + 'card' => [ + 'code6x4' => '424242XXXXXX4242', + 'network' => 'VISA', + ], + 'details' => [ + 'fullName' => 'John Doe', + 'validityDate' => '2027-12', + 'selectedBrand' => 'VISA', + ], + ], + ])]); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame('BE', $card->getCountryCode()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2027, $card->getExpirationYear()); + } + + public function testInvoke_whenSaveCardRequestedAndFetchingTheOperationFails_stillPersistsTheCardUsingDetailsFallback(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom')); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + /** + * @dataProvider malformedOperationResponseBodyProvider + */ + public function testInvoke_whenSaveCardRequestedAndOperationResponseShapeIsMalformed_stillPersistsTheCardUsingDetailsFallback( + string $body, + ): void { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000","operationIds":["op_1"]}', null, null, 'alias_new_1')); + + $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + self::assertSame('FR', $card->getCountryCode()); + } + + /** @return array */ + public static function malformedOperationResponseBodyProvider(): array + { + return [ + 'non-array body' => ['"just a string"'], + 'paymentMethod key missing' => [json_encode(['id' => 'op_1'])], + 'card key missing' => [json_encode(['paymentMethod' => ['details' => ['selectedBrand' => 'VISA']]])], + 'details key missing' => [json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]])], + 'validityDate does not match the expected YYYY-MM format' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]])], + 'validityDate has an out-of-range month' => [json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]])], + 'code6x4 shorter than 4 characters' => [json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]])], + ]; + } + + public function testInvoke_whenSaveCardNotRequested_neverPersistsACard(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => false, + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedButNoAliasReturned_neverPersistsACard(): void + { + $this->paymentRequestWithPayment([ + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_save_card' => true, + ]); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null)); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } + + public function testInvoke_whenSaveCardRequestedButMethodIsNotCorePaymentMethod_neverPersistsACard(): void + { + // Card::$paymentMethod requires Sylius Core's PaymentMethodInterface, which every real + // Sylius-wired payment method satisfies — a test double built against only the base + // Payment component's PaymentMethodInterface exercises the guard that skips persisting a + // card entirely rather than flushing one with that mandatory field left unset. + $method = $this->createMock(BasePaymentMethodInterface::class); + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['hfIdentifier' => 'acct_123']); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('customer@example.com'); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn(['hosted_fields_token' => 'hf_token_abc', 'hosted_fields_save_card' => true]); + $payment->method('getMethod')->willReturn($method); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + $this->unifiedApiPaymentCreator->method('createPayment') + ->willReturn(new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, 'alias_new_1')); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->__invoke(new CaptureHostedPaymentRequest(null)); + } +} diff --git a/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php new file mode 100644 index 00000000..1e15f62a --- /dev/null +++ b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php @@ -0,0 +1,223 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->lock = $this->createMock(ILock::class); + $this->paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + // The handler scopes before reading; these tests stub one account, so it scopes to itself. + $this->configurationRepository->method('forPaymentMethod')->willReturnSelf(); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new NotifyHostedPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->lock, + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->logger, + ); + } + + private function paymentRequestWithPayload( + array $httpRequest, + int $paymentId = 42, + int $paymentAmount = 1000, + ): PaymentRequestInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn($paymentId); + $payment->method('getAmount')->willReturn($paymentAmount); + // The handler resolves the webhook secret from the payment's own method's gateway config. + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayload')->willReturn(['http_request' => $httpRequest]); + $paymentRequest->method('getPayment')->willReturn($payment); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + public function testInvoke_onValidNotification_savesTreatsAndAppliesTheOutcome(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $paymentRequest = $this->paymentRequestWithPayload([ + 'content' => $body, + 'headers' => ['Authorization' => ['Bearer shared-secret']], + ]); + + $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + $this->lock->expects(self::once())->method('release'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_onPendingThreeDsOutcome_doesNothingAndReleasesTheLock(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]); + $this->paymentRequestWithPayload([ + 'content' => $body, + 'headers' => ['Authorization' => ['Bearer shared-secret']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->lock->expects(self::once())->method('release'); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_whenLockIsHeld_releasesNothingAndCompletesWithoutApplying(): void + { + $paymentRequest = $this->paymentRequestWithPayload(['content' => '{}', 'headers' => []]); + $this->lock->method('acquire')->willReturn(false); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_whenAlreadyTreated_isIdempotentAndDoesNotReapply(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $this->paymentRequestWithPayload([ + 'content' => $body, + 'headers' => ['Authorization' => ['Bearer shared-secret']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->lock->expects(self::once())->method('release'); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_whenOrderIdDoesNotMatchThePaymentRequestsOwnPayment_failsWithoutApplyingTheOutcome(): void + { + // The webhook body claims to be about order/payment "999", but the notify hash this + // request arrived on belongs to a PaymentRequest whose own payment id is 42. Applying the + // outcome here would mutate the wrong payment. + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '999', 'amount' => 1000]); + $paymentRequest = $this->paymentRequestWithPayload([ + 'content' => $body, + 'headers' => ['Authorization' => ['Bearer shared-secret']], + ], 42, 1000); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('error'); + $this->lock->expects(self::once())->method('release'); + $paymentRequest->expects(self::once())->method('setResponseData'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_whenAmountDoesNotMatchThePaymentRequestsOwnPayment_failsWithoutApplyingTheOutcome(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]); + $paymentRequest = $this->paymentRequestWithPayload([ + 'content' => $body, + 'headers' => ['Authorization' => ['Bearer shared-secret']], + ], 42, 1000); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('error'); + $this->lock->expects(self::once())->method('release'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } + + public function testInvoke_onInvalidSignature_logsReleasesTheLockAndFailsThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithPayload([ + 'content' => '{}', + 'headers' => ['Authorization' => ['Bearer wrong-secret']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->lock->method('acquire')->willReturn(true); + + $this->logger->expects(self::once())->method('error'); + $this->lock->expects(self::once())->method('release'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->handler->__invoke(new NotifyHostedPaymentRequest(null)); + } +} diff --git a/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php new file mode 100644 index 00000000..064d7b12 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php @@ -0,0 +1,175 @@ +paymentRequestProvider = $this->createMock(PaymentRequestProviderInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->operationStatusFetcher = $this->createMock(OperationStatusFetcherInterface::class); + $this->webhookNotificationHandler = $this->createMock(HostedFieldsWebhookNotificationHandler::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->handler = new StatusHostedPaymentRequestHandler( + $this->paymentRequestProvider, + $this->stateMachine, + $this->operationStatusFetcher, + $this->webhookNotificationHandler, + $this->logger, + ); + } + + /** @param mixed[] $details */ + private function paymentRequest( + string $state = PaymentInterface::STATE_PROCESSING, + array $details = [], + ): PaymentRequestInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getState')->willReturn($state); + $payment->method('getDetails')->willReturn($details); + // The poll authenticates against the payment's own method's account. + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $this->paymentRequestProvider->method('provide')->willReturn($paymentRequest); + + return $paymentRequest; + } + + public function testInvoke_withNoForcedStatus_andPaymentAlreadyResolved_skipsPollingAndCompletesRequest(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_COMPLETED, ['hosted_fields_operation_id' => 'op_123']); + + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withNoForcedStatus_andNoOperationIdStored_skipsPollingAndCompletesRequest(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, []); + + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withNoForcedStatus_andFinalExecCode_appliesOutcomeViaWebhookHandler(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']); + $payment = $paymentRequest->getPayment(); + $body = json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000072', 'amount' => 7400]); + + $this->operationStatusFetcher->expects(self::once())->method('getOperation')->with('op_123') + ->willReturn(['status' => 200, 'body' => $body]); + $this->webhookNotificationHandler->expects(self::once())->method('treat')->with($payment, $body, []); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withNoForcedStatus_andPendingExecCode_stillDelegatesToWebhookHandler(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']); + $payment = $paymentRequest->getPayment(); + $body = json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '000000072', 'amount' => 7400]); + + $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]); + $this->webhookNotificationHandler->expects(self::once())->method('treat')->with($payment, $body, []); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withNoForcedStatus_whenFetcherFails_logsAndStillCompletesRequest(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']); + + $this->operationStatusFetcher->method('getOperation')->willThrowException(new ApiException('boom')); + $this->webhookNotificationHandler->expects(self::never())->method('treat'); + $this->logger->expects(self::once())->method('error'); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withNoForcedStatus_whenWebhookHandlerRejectsThePayload_logsAndStillCompletesRequest(): void + { + $paymentRequest = $this->paymentRequest(PaymentInterface::STATE_PROCESSING, ['hosted_fields_operation_id' => 'op_123']); + $body = json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '000000072', 'amount' => 7400]); + + $this->operationStatusFetcher->method('getOperation')->willReturn(['status' => 200, 'body' => $body]); + $this->webhookNotificationHandler->method('treat')->willThrowException(new InvalidNotificationException('mismatch')); + $this->logger->expects(self::once())->method('error'); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null)); + } + + public function testInvoke_withForcedCanceledStatus_cancelsThePaymentWhenAllowed(): void + { + $paymentRequest = $this->paymentRequest(); + $payment = $paymentRequest->getPayment(); + + $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL)->willReturn(true); + $this->operationStatusFetcher->expects(self::never())->method('getOperation'); + $this->stateMachine->expects(self::exactly(2))->method('apply'); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled')); + } + + public function testInvoke_withForcedCanceledStatus_whenTransitionNotAllowed_stillCompletesThePaymentRequest(): void + { + $paymentRequest = $this->paymentRequest(); + + $this->stateMachine->method('can')->willReturn(false); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_COMPLETE); + + $this->handler->__invoke(new StatusHostedPaymentRequest(null, 'canceled')); + } +} diff --git a/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php new file mode 100644 index 00000000..c05fbe86 --- /dev/null +++ b/tests/PHPUnit/Command/Provider/CaptureHostedPaymentRequestCommandProviderTest.php @@ -0,0 +1,136 @@ +setSession(new Session(new MockArraySessionStorage())); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + + $this->provider = new CaptureHostedPaymentRequestCommandProvider(new SelectedCardResolver($this->requestStack, $this->payplugCardRepository)); + } + + private function paymentRequestWithDetails(array $details): PaymentRequestInterface&MockObject + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn($details); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getId')->willReturn('1'); + + return $paymentRequest; + } + + public function testProvide_withNoCardSelected_returnsCaptureHostedPaymentRequest(): void + { + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withOtherCardSentinelSelected_returnsCaptureHostedPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', 'other'); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withExistingCardSelectedAndNotYetCaptured_returnsCaptureAliasPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(new Card()); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withExistingCardSelectedButAlreadyCaptured_returnsOfflineCaptureRequest(): void + { + $card = new Card(); + $card->setExternalId('alias_existing_1'); + + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_existing_1', + ]); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withDifferentCardSelectedAfterEarlierAliasAttempt_returnsFreshCaptureAliasPaymentRequest(): void + { + $card = new Card(); + $card->setExternalId('alias_new'); + + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_old', + ]); + + self::assertInstanceOf(CaptureAliasPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withNoCardSelectedAfterEarlierAliasAttempt_returnsOfflineCaptureRequestInsteadOfADuplicateCapture(): void + { + $paymentRequest = $this->paymentRequestWithDetails([ + 'alias_payment_created_at' => '2026-08-17T10:00:00+00:00', + 'alias_id' => 'alias_old', + ]); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withHostedFieldsTokenAlreadyCaptured_returnsOfflineCaptureRequest(): void + { + $paymentRequest = $this->paymentRequestWithDetails(['hosted_fields_created_at' => '2026-08-17T10:00:00+00:00']); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_withSelectedCardIdNoLongerFound_fallsBackToCaptureHostedPaymentRequest(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null); + $paymentRequest = $this->paymentRequestWithDetails([]); + + self::assertInstanceOf(CaptureHostedPaymentRequest::class, $this->provider->provide($paymentRequest)); + } +} diff --git a/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php new file mode 100644 index 00000000..2801cbfb --- /dev/null +++ b/tests/PHPUnit/Command/Provider/CapturePaymentRequestCommandProviderTest.php @@ -0,0 +1,103 @@ +hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class); + $this->provider = new CapturePaymentRequestCommandProvider($this->hostedFieldsCommandProvider); + } + + public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME, + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + + $expected = new CaptureHostedPaymentRequest('1'); + $this->hostedFieldsCommandProvider->expects(self::once())->method('provide') + ->with($paymentRequest)->willReturn($expected); + + self::assertSame($expected, $this->provider->provide($paymentRequest)); + } + + public function testProvide_forPayplugWithoutHostedFields_usesLegacyFlow(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME, + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => false], + ]); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + /** + * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of + * their own config shape, since it's gated on the `payplug` factory name first — behavior for + * them must stay exactly as it was before this delegation was introduced. + */ + public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => 'payplug_oney', + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void + { + $paymentRequest = $this->paymentRequestWithConfig(null); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(CapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_whenAlreadyCreated_returnsOfflineCaptureRequest(): void + { + $paymentRequest = $this->paymentRequestWithConfig( + ['factoryName' => PayPlugGatewayFactory::FACTORY_NAME, 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => false]], + ['status' => 'captured', 'payment_id' => 'pay_1'], + ); + + self::assertInstanceOf(OfflineCapturePaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testSupports_onlyForCaptureAction(): void + { + $captureRequest = $this->createMock(PaymentRequestInterface::class); + $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE); + self::assertTrue($this->provider->supports($captureRequest)); + + $notifyRequest = $this->createMock(PaymentRequestInterface::class); + $notifyRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_NOTIFY); + self::assertFalse($this->provider->supports($notifyRequest)); + } +} diff --git a/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php new file mode 100644 index 00000000..ef0d431b --- /dev/null +++ b/tests/PHPUnit/Command/Provider/NotifyPaymentRequestCommandProviderTest.php @@ -0,0 +1,80 @@ +hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class); + $this->provider = new NotifyPaymentRequestCommandProvider($this->hostedFieldsCommandProvider); + } + + public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME, + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + + $expected = new NotifyHostedPaymentRequest('1'); + $this->hostedFieldsCommandProvider->expects(self::once())->method('provide') + ->with($paymentRequest)->willReturn($expected); + + self::assertSame($expected, $this->provider->provide($paymentRequest)); + } + + /** + * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of + * their own config shape, since it's gated on the `payplug` factory name first — behavior for + * them must stay exactly as it was before this delegation was introduced. + */ + public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => 'payplug_oney', + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(NotifyPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void + { + $paymentRequest = $this->paymentRequestWithConfig(null); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(NotifyPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testSupports_onlyForNotifyAction(): void + { + $notifyRequest = $this->createMock(PaymentRequestInterface::class); + $notifyRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_NOTIFY); + self::assertTrue($this->provider->supports($notifyRequest)); + + $captureRequest = $this->createMock(PaymentRequestInterface::class); + $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE); + self::assertFalse($this->provider->supports($captureRequest)); + } +} diff --git a/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php b/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php new file mode 100644 index 00000000..91859e0f --- /dev/null +++ b/tests/PHPUnit/Command/Provider/PaymentRequestWithGatewayConfigTrait.php @@ -0,0 +1,46 @@ +}|null $gatewayConfig + * @param array $details + */ + private function paymentRequestWithConfig( + ?array $gatewayConfig, + array $details = [], + ): PaymentRequestInterface&MockObject + { + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $gatewayConfig) { + $config = $this->createMock(GatewayConfigInterface::class); + $config->method('getFactoryName')->willReturn($gatewayConfig['factoryName']); + $config->method('getConfig')->willReturn($gatewayConfig['config']); + $method->method('getGatewayConfig')->willReturn($config); + } + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + $payment->method('getDetails')->willReturn($details); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getId')->willReturn('1'); + + return $paymentRequest; + } +} diff --git a/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php b/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php new file mode 100644 index 00000000..248753df --- /dev/null +++ b/tests/PHPUnit/Command/Provider/StatusPaymentRequestCommandProviderTest.php @@ -0,0 +1,87 @@ +requestStack = $this->createMock(RequestStack::class); + $this->hostedFieldsCommandProvider = $this->createMock(PaymentRequestCommandProviderInterface::class); + $this->provider = new StatusPaymentRequestCommandProvider($this->requestStack, $this->hostedFieldsCommandProvider); + } + + public function testProvide_forPayplugWithHostedFieldsEnabled_delegatesToHostedFieldsProvider(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => PayPlugGatewayFactory::FACTORY_NAME, + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + + $expected = new StatusHostedPaymentRequest('1'); + $this->hostedFieldsCommandProvider->expects(self::once())->method('provide') + ->with($paymentRequest)->willReturn($expected); + $this->requestStack->expects(self::never())->method('getCurrentRequest'); + + self::assertSame($expected, $this->provider->provide($paymentRequest)); + } + + /** + * Other gateways (Oney, Bancontact, ...) never satisfy the Hosted Fields check regardless of + * their own config shape, since it's gated on the `payplug` factory name first — behavior for + * them must stay exactly as it was before this delegation was introduced. + */ + public function testProvide_forOtherGatewayFactory_neverDelegatesEvenIfConfigHappensToHaveHostedFieldsKey(): void + { + $paymentRequest = $this->paymentRequestWithConfig([ + 'factoryName' => 'payplug_oney', + 'config' => [PayPlugGatewayFactory::HOSTED_FIELDS => true], + ]); + $this->requestStack->method('getCurrentRequest')->willReturn(null); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(StatusPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testProvide_whenNoGatewayConfig_usesLegacyFlow(): void + { + $paymentRequest = $this->paymentRequestWithConfig(null); + $this->requestStack->method('getCurrentRequest')->willReturn(null); + + $this->hostedFieldsCommandProvider->expects(self::never())->method('provide'); + + self::assertInstanceOf(StatusPaymentRequest::class, $this->provider->provide($paymentRequest)); + } + + public function testSupports_onlyForStatusAction(): void + { + $statusRequest = $this->createMock(PaymentRequestInterface::class); + $statusRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_STATUS); + self::assertTrue($this->provider->supports($statusRequest)); + + $captureRequest = $this->createMock(PaymentRequestInterface::class); + $captureRequest->method('getAction')->willReturn(PaymentRequestInterface::ACTION_CAPTURE); + self::assertFalse($this->provider->supports($captureRequest)); + } +} diff --git a/tests/PHPUnit/Config/HostedFieldsSdkUrlConfigurationTest.php b/tests/PHPUnit/Config/HostedFieldsSdkUrlConfigurationTest.php new file mode 100644 index 00000000..d2901b11 --- /dev/null +++ b/tests/PHPUnit/Config/HostedFieldsSdkUrlConfigurationTest.php @@ -0,0 +1,48 @@ +paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->cartContext = $this->createMock(CartContextInterface::class); + $this->paymentDataCreator = $this->createMock(PayPlugPaymentDataCreator::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + $this->controller = new IntegratedPaymentController( + $this->cartContext, + $this->paymentMethodRepository, + $this->createMock(OrderRepositoryInterface::class), + $this->paymentDataCreator, + $this->apiClientFactory, + $this->createMock(EntityManagerInterface::class), + $this->createMock(LoggerInterface::class), + ); + } + + /** + * The iframe posts a payment method id, so the account the payment is created on must be the + * one configured on *that* payment method. Resolving the client by factory name instead would + * pick an arbitrary one of the several gateway configs that may now share it — one per channel + * since PRE-3628 — and create the payment on another channel's PayPlug account. + */ + public function testInitPayment_createsThePaymentOnTheAccountOfTheSubmittedPaymentMethod(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->paymentDataCreator->method('create')->willReturn(new ArrayObject()); + + $payplugPayment = $this->createMock(PayplugPayment::class); + $payplugPayment->id = 'pay_1'; + $payplugPayment->is_live = false; + + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('createPayment')->willReturn($payplugPayment); + + $this->apiClientFactory->expects(self::once()) + ->method('createForPaymentMethod') + ->with(self::identicalTo($paymentMethod)) + ->willReturn($apiClient); + + $response = $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + + self::assertSame(201, $response->getStatusCode()); + } + + /** + * The id is shopper-supplied. With one CB gateway per channel — each connected to a different + * PayPlug account since PRE-3628 — accepting a payment method that does not belong to the + * order's channel would create the payment on another merchant's account, and the IPN would + * then verify it against that merchant's key and mark this order paid. + */ + public function testInitPayment_refusesAPaymentMethodFromAnotherChannel(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_IT'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('not available on this channel'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + /** + * A disconnected gateway is left disabled by GatewayConnectionRevoker, and its credentials are + * gone — creating a payment on it would fail at best and resolve stale credentials at worst. + */ + public function testInitPayment_refusesADisabledPaymentMethod(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR', enabled: false); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('not enabled'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + public function testInitPayment_refusesAPaymentMethodOfAnotherFactory(): void + { + $paymentMethod = $this->payPlugPaymentMethod(channelCode: 'WEB_FR', factoryName: 'payplug_oney'); + $this->paymentMethodRepository->method('find')->with(42)->willReturn($paymentMethod); + + $this->givenCartOnChannel('WEB_FR'); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Unsupported payment method of Integrated Payment'); + + $this->controller->initPaymentAction(Request::create('/payplug/integrated-payment/init/42'), 42); + } + + private function givenCartOnChannel(string $channelCode): void + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($this->createMock(PaymentInterface::class)); + $order->method('getChannel')->willReturn($channel); + + $this->cartContext->method('getCart')->willReturn($order); + } + + private function payPlugPaymentMethod( + string $channelCode, + bool $enabled = true, + string $factoryName = PayPlugGatewayFactory::FACTORY_NAME, + ): PaymentMethodInterface&MockObject { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($channelCode); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Controller/IpnActionTest.php b/tests/PHPUnit/Controller/IpnActionTest.php new file mode 100644 index 00000000..f8ad7e49 --- /dev/null +++ b/tests/PHPUnit/Controller/IpnActionTest.php @@ -0,0 +1,104 @@ +logger = $this->createMock(LoggerInterface::class); + $this->paymentNotificationHandler = $this->createMock(PaymentNotificationHandler::class); + $this->refundNotificationHandler = $this->createMock(RefundNotificationHandler::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + + $this->action = new IpnAction( + $this->logger, + $this->paymentNotificationHandler, + $this->refundNotificationHandler, + $this->apiClientFactory, + $this->paymentRepository, + $this->entityManager, + ); + } + + private function paymentWithGatewayConfig(): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + return $payment; + } + + /** + * The webhook must sign its `treat()` call with the credentials of the account the payment was + * actually created on. Resolving the client by factory name instead would pick an arbitrary one + * of the several gateway configs that may now share it — one per channel since PRE-3628. + */ + public function testInvoke_forALegacyPayment_buildsTheApiClientFromThePaymentsOwnMethod(): void + { + $payment = $this->paymentWithGatewayConfig(); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->apiClientFactory->expects(self::once()) + ->method('createForPaymentMethod') + ->with(self::identicalTo($payment->getMethod())) + ->willReturn($this->createMock(PayPlugApiClientInterface::class)); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_whenPaymentIsNotFound_returnsUnauthorized(): void + { + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn(null); + + $request = Request::create('/payplug/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } +} diff --git a/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php new file mode 100644 index 00000000..d95658dd --- /dev/null +++ b/tests/PHPUnit/Controller/UnifiedApiIpnActionTest.php @@ -0,0 +1,160 @@ +logger = $this->createMock(LoggerInterface::class); + $this->hostedFieldsWebhookNotificationHandler = $this->createMock(HostedFieldsWebhookNotificationHandler::class); + $this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + + $this->action = new UnifiedApiIpnAction( + $this->logger, + $this->hostedFieldsWebhookNotificationHandler, + $this->paymentRepository, + ); + } + + private function paymentWithGatewayConfig(bool $hostedFields): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => $hostedFields]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + return $payment; + } + + public function testInvoke_forAHostedFieldsPayment_delegatesToTheWebhookNotificationHandler(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn($payment); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000'])); + $request->headers->set('Authorization', 'Bearer shared-secret'); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat') + ->with($payment, $request->getContent(), self::callback(static fn (array $headers): bool => 'Bearer shared-secret' === ($headers['authorization'] ?? null))); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_forAHostedFieldsPayment_whenNotificationIsInvalid_logsAndStillReturns200(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); + $this->hostedFieldsWebhookNotificationHandler->method('treat')->willThrowException(new InvalidNotificationException('boom')); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->logger->expects(self::once())->method('error'); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_forANonUnifiedApiPayment_returnsUnauthorizedWithoutCallingTheWebhookNotificationHandler(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: false); + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn($payment); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat'); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } + + public function testInvoke_whenPaymentIsNotFound_returnsUnauthorized(): void + { + $this->paymentRepository->method('findOneByPayPlugPaymentId')->willReturn(null); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } + + public function testInvoke_whenBodyHasNoId_returnsUnauthorized(): void + { + $this->paymentRepository->expects(self::never())->method('findOneByPayPlugPaymentId'); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: '{}'); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } + + /** + * PayPlug's webhook can be delivered before CaptureHostedPaymentRequestHandler's own + * hosted_fields_payment_id/hosted_fields_operation_id write has committed (Sylius's + * doctrine_transaction messenger middleware only commits once that whole handler returns) — + * findOneByPayPlugPaymentId() briefly returns null for a payment that does exist. + */ + public function testInvoke_whenPaymentNotYetVisibleOnFirstLookup_retriesAndStillDelegatesToTheWebhookNotificationHandler(): void + { + $payment = $this->paymentWithGatewayConfig(hostedFields: true); + $this->paymentRepository->expects(self::exactly(3))->method('findOneByPayPlugPaymentId') + ->with('pay_1') + ->willReturnOnConsecutiveCalls(null, null, $payment); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1', 'execCode' => '0000'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::once())->method('treat') + ->with($payment, $request->getContent(), self::isType('array')); + + $response = $this->action->__invoke($request); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testInvoke_whenPaymentNeverBecomesVisible_stopsRetryingAndReturnsUnauthorized(): void + { + $this->paymentRepository->expects(self::exactly(4))->method('findOneByPayPlugPaymentId')->with('pay_1')->willReturn(null); + + $request = Request::create('/payplug/v2/ipn', 'POST', content: \json_encode(['id' => 'pay_1'])); + + $this->hostedFieldsWebhookNotificationHandler->expects(self::never())->method('treat'); + + $response = $this->action->__invoke($request); + + self::assertSame(401, $response->getStatusCode()); + } +} diff --git a/tests/PHPUnit/Entity/PayPlugOperationTest.php b/tests/PHPUnit/Entity/PayPlugOperationTest.php new file mode 100644 index 00000000..68cc1173 --- /dev/null +++ b/tests/PHPUnit/Entity/PayPlugOperationTest.php @@ -0,0 +1,46 @@ +getOrderId()); + self::assertSame('op_123', $operation->getOperationId()); + self::assertSame('0000', $operation->getExecCode()); + self::assertSame(PaymentOutcome::PAID, $operation->getOutcome()); + self::assertSame(1000, $operation->getAmount()); + self::assertFalse($operation->isTreated()); + } + + public function testMarkTreated_setsTreatedToTrue(): void + { + $operation = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + + $operation->markTreated(); + + self::assertTrue($operation->isTreated()); + } + + public function testToOperationData_returnsEquivalentValueObject(): void + { + $operation = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + + $data = $operation->toOperationData(); + + self::assertSame('op_123', $data->operationId); + self::assertSame('0000', $data->execCode); + self::assertSame(PaymentOutcome::PAID, $data->outcome); + self::assertSame(1000, $data->amount); + self::assertSame('42', $data->orderId); + } +} diff --git a/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php new file mode 100644 index 00000000..f4212c4c --- /dev/null +++ b/tests/PHPUnit/EventSubscriber/PostPaymentSelectEventSubscriberTest.php @@ -0,0 +1,405 @@ +requestStack = $this->createMock(RequestStack::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->hostedFieldsPaymentProcessor = $this->createMock(HostedFieldsPaymentProcessorInterface::class); + + $this->subscriber = new PostPaymentSelectEventSubscriber( + $this->requestStack, + $this->entityManager, + $this->stateMachine, + $this->hostedFieldsPaymentProcessor, + ); + } + + public function testHandle_withHostedFieldsToken_delegatesToProcessorAndCompletesCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => true]), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::once()) + ->method('process') + ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR')) + ; + + $this->stateMachine->method('can') + ->with($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE) + ->willReturn(true) + ; + $this->stateMachine->expects(self::once())->method('apply'); + $this->entityManager->expects(self::once())->method('flush'); + + $this->subscriber->handle($event); + } + + /** + * A crafted POST carrying a hosted fields token must not be able to complete checkout + * for a payment method that does not have Hosted Fields enabled. + */ + public function testHandle_withHostedFieldsTokenButHostedFieldsNotEnabled_doesNotProcessNorCompleteCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => false]), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + /** + * A crafted POST carrying a hosted fields token must not be able to complete checkout + * for a payment method on a different gateway entirely, even if that gateway's config + * coincidentally has a truthy value under the same HOSTED_FIELDS key. The factory-name + * check must still gate first. + */ + public function testHandle_withHostedFieldsTokenButDifferentFactory_doesNotProcessNorCompleteCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod('offline', [PayPlugGatewayFactory::HOSTED_FIELDS => true]), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + /** + * A payplug payment method whose config predates the Hosted Fields flag (key absent + * entirely, e.g. a legacy config) must not be treated as Hosted-Fields-enabled. Pins + * the `?? false` default explicitly, distinct from an explicit `false` value. + */ + public function testHandle_withHostedFieldsTokenButConfigKeyAbsent_doesNotProcessNorCompleteCheckout(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + 'hostedfields_save_card' => 'true', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, []), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->stateMachine->expects(self::never())->method('apply'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + /** + * Pins the dispatch precedence that alterRequestConfigurationForInlineCardCapture() mirrors: + * when both token fields are present, handle() treats the request as Hosted Fields (no + * payment_id is ever written). Flipping this order without flipping the redirect ternary would + * send a payment_id-less order to sylius_shop_order_pay. + */ + public function testHandle_withBothTokens_isProcessedAsHostedFields(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'payplug_integrated_payment_token' => 'pay_123', + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'CB', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn( + $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [PayPlugGatewayFactory::HOSTED_FIELDS => true]), + ); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + $payment->method('getOrder')->willReturn($order); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + // Hosted Fields path: the processor is used and no payment_id is written to the details. + $this->hostedFieldsPaymentProcessor->expects(self::once()) + ->method('process') + ->with($payment, new HostedFieldsCaptureData('hf_token_abc', 'CB', false, '4242', 12, 2030, 'FR')) + ; + $payment->expects(self::never())->method('setDetails'); + + $this->subscriber->handle($event); + } + + public function testHandle_withHostedFieldsTokenButNoPaymentMethod_doesNotProcess(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_last4' => '4242', + 'hostedfields_exp_month' => '12', + 'hostedfields_exp_year' => '2030', + 'hostedfields_country' => 'FR', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn(null); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + public function testHandle_withoutAnyToken_doesNothing(): void + { + $request = Request::create('/checkout/select-payment', 'POST'); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $this->requestStack->method('getCurrentRequest')->willReturn($request); + + $payment = $this->createMock(PaymentInterface::class); + $order = $this->createMock(OrderInterface::class); + $order->method('getLastPayment')->willReturn($payment); + + $event = $this->createMock(ResourceControllerEvent::class); + $event->method('getSubject')->willReturn($order); + + $this->hostedFieldsPaymentProcessor->expects(self::never())->method('process'); + $this->entityManager->expects(self::never())->method('flush'); + + $this->subscriber->handle($event); + } + + // ------------------------------------------------------------------------- + // alterRequestConfigurationForInlineCardCapture() + // ------------------------------------------------------------------------- + + /** + * Integrated Payment relays a real PayPlug payment_id, so the redirect override to + * `sylius_shop_order_pay` (Payum capture/status) must stay in place. + */ + public function testAlterRequestConfiguration_withIntegratedPaymentToken_overridesRedirect(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'payplug_integrated_payment_token' => 'pay_123', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame( + [ + 'redirect' => [ + 'route' => 'sylius_shop_order_pay', + 'parameters' => ['tokenValue' => 'resource.tokenValue'], + ], + ], + $request->attributes->get('_sylius'), + ); + } + + /** + * Both Integrated Payment and Hosted Fields target `sylius_shop_order_pay` (Payum + * capture/status for Integrated Payment; for Hosted Fields, the same `payplug`-tagged + * Capture/Notify/StatusPaymentRequestCommandProvider trio delegates to their + * Hosted-Fields-specific counterparts — see PayPlugGatewayFactory::isHostedFieldsConfig() — + * so the payment is actually created/confirmed through UPC. + */ + public function testAlterRequestConfigurationForInlineCardCapture_forHostedFieldsToken_redirectsToOrderPay(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'hostedfields_token' => 'hf_token_abc', + 'hostedfields_selected_brand' => 'VISA', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', ['redirect' => ['route' => 'sylius_shop_checkout_complete']]); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame( + [ + 'redirect' => [ + 'route' => 'sylius_shop_order_pay', + 'parameters' => ['tokenValue' => 'resource.tokenValue'], + ], + ], + $request->attributes->get('_sylius'), + ); + } + + /** + * A crafted request carrying both token fields is dispatched as Hosted Fields by handle() + * (it checks hasHostedFieldsToken() first). Since PRE-3551 both paths redirect to the same + * route, so this just pins that the redirect override still applies regardless of which + * token(s) are present. + */ + public function testAlterRequestConfiguration_withBothTokens_followsHandleAndRedirectsToOrderPay(): void + { + $request = Request::create('/checkout/select-payment', 'POST', [ + 'payplug_integrated_payment_token' => 'pay_123', + 'hostedfields_token' => 'hf_token_abc', + ]); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $request->attributes->set('_sylius', []); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + $syliusRequestConfig = $request->attributes->get('_sylius'); + self::assertSame('sylius_shop_order_pay', $syliusRequestConfig['redirect']['route']); + } + + public function testAlterRequestConfiguration_withoutAnyToken_leavesRedirectUntouched(): void + { + $request = Request::create('/checkout/select-payment', 'POST'); + $request->attributes->set('_route', 'sylius_shop_checkout_select_payment'); + $syliusRequestConfig = ['redirect' => ['route' => 'sylius_shop_checkout_complete']]; + $request->attributes->set('_sylius', $syliusRequestConfig); + + $this->subscriber->alterRequestConfigurationForInlineCardCapture($this->buildRequestEvent($request)); + + self::assertSame($syliusRequestConfig, $request->attributes->get('_sylius')); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildRequestEvent(Request $request): RequestEvent + { + return new RequestEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php new file mode 100644 index 00000000..ebc4732a --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php @@ -0,0 +1,242 @@ + gatewayConfig -> config). + * + * The base-currency-per-channel check that used to also be exercised here (via + * AbstractGatewayConfigurationType's own PRE_SUBMIT listener) has moved to + * PaymentMethodTypeExtension's POST_SUBMIT listener on the root PaymentMethodType form; it is + * covered by AbstractGatewayConfigurationTypeTest and PayPlugGatewayConfigurationTypeTest's hook + * tests instead, since this file's minimal 3-level tree doesn't wire up PaymentMethodTypeExtension. + */ +final class PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest extends TypeTestCase +{ + use ValidatorExtensionTrait; + + private const ACCOUNT_ID_ERROR = 'payplug_sylius_payplug_plugin.form.account_id_required'; + + protected function getTypes(): array + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + return [ + new PayPlugGatewayConfigurationType($translator), + ]; + } + + protected function getTypeExtensions(): array + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + return [ + new PayPlugGatewayConfigurationTypeExtension($translator), + ]; + } + + public function testSubmit_hostedFieldsModeWithBlankIdentifier_isInvalidWithAccountIdError(): void + { + $form = $this->createRootForm(); + + $form->submit([ + 'gatewayConfig' => [ + 'config' => [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HF_IDENTIFIER => '', + ], + ], + ]); + + self::assertTrue($form->isSubmitted()); + self::assertFalse($form->isValid(), 'Form must be invalid when hosted_fields is selected but the account id is blank.'); + + $configForm = $form->get('gatewayConfig')->get('config'); + + $identifierErrors = $configForm->get(PayPlugGatewayFactory::HF_IDENTIFIER)->getErrors(); + self::assertCount(1, $identifierErrors); + self::assertSame(self::ACCOUNT_ID_ERROR, $identifierErrors[0]->getMessage()); + } + + /** + * The account id is now the only hosted-fields requirement — the SubMerchant ID field it used + * to be paired with is gone, so filling this one alone must be enough to save the form. + */ + public function testSubmit_hostedFieldsModeWithIdentifierFilled_isValid(): void + { + $form = $this->createRootForm(); + + $form->submit([ + 'gatewayConfig' => [ + 'config' => [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ], + ], + ]); + + self::assertTrue($form->isSubmitted()); + self::assertTrue($form->isValid()); + } + + /** + * Regression coverage for "the radio reverts to redirected on reload": DISPLAY_MODE_FIELD is + * `mapped => false`, and the PRE_SET_DATA listener that used to pre-select it there got its + * setData() call silently overwritten by Symfony's own DataMapper::mapDataToForms(), which + * resets every unmapped child back to its configured (null) default immediately after + * PRE_SET_DATA dispatches, before POST_SET_DATA fires. Moving the pre-selection to + * POST_SET_DATA fixes it, since nothing runs after that to reset it again. + */ + public function testSetData_existingIntegratedPaymentConfig_preselectsIntegratedPaymentRadio(): void + { + $form = $this->createRootForm(); + $configForm = $form->get('gatewayConfig')->get('config'); + + $configForm->setData([ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + PayPlugGatewayFactory::HOSTED_FIELDS => false, + ]); + + self::assertSame( + PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + $configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(), + ); + } + + public function testSetData_existingHostedFieldsConfig_preselectsHostedFieldsRadio(): void + { + $form = $this->createRootForm(); + $configForm = $form->get('gatewayConfig')->get('config'); + + $configForm->setData([ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ]); + + self::assertSame( + PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + $configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData(), + ); + } + + public function testSetData_neitherFlagSet_leavesRadioUnselected(): void + { + $form = $this->createRootForm(); + $configForm = $form->get('gatewayConfig')->get('config'); + + $configForm->setData([ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => false, + ]); + + self::assertNull($configForm->get(PayPlugGatewayFactory::DISPLAY_MODE_FIELD)->getData()); + } + + public function testSubmit_integratedPaymentMode_withBlankFields_isValid(): void + { + // The conditional requirement only applies to hosted_fields; other modes must not be + // affected by a blank identifier field. + $form = $this->createRootForm(); + + $form->submit([ + 'gatewayConfig' => [ + 'config' => [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + PayPlugGatewayFactory::HF_IDENTIFIER => '', + ], + ], + ]); + + self::assertTrue($form->isValid()); + } + + /** + * A payment method configured before the SubMerchant ID field was removed still carries + * `hfSubMerchantId` in its stored config. Re-saving it must not fail on the now-unknown key, + * and the leftover value is simply ignored — GatewayCredentialsResolver no longer reads it. + */ + public function testSubmit_hostedFieldsModeWithALeftoverSubMerchantIdInStoredConfig_isValid(): void + { + $form = $this->createRootForm(); + $configForm = $form->get('gatewayConfig')->get('config'); + $configForm->setData([ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + 'hfSubMerchantId' => 'sub_456', + ]); + + $form->submit([ + 'gatewayConfig' => [ + 'config' => [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ], + ], + ]); + + self::assertTrue($form->isValid()); + } + + /** + * Builds a minimal but realistic 3-level tree: root (the PaymentMethod form, exposing + * "channels") -> gatewayConfig -> config (PayPlugGatewayConfigurationType, the type under + * test). + */ + private function createRootForm(): \Symfony\Component\Form\FormInterface + { + $paymentMethod = new class() { + public function getId(): ?int + { + return 1; + } + }; + + $root = $this->factory->createBuilder(FormType::class, $paymentMethod, ['data_class' => null]); + $root->add('channels', TextType::class, ['mapped' => false]); + + $gatewayConfig = $root->create('gatewayConfig', FormType::class, ['mapped' => false]); + $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class); + + $root->add($gatewayConfig); + + return $root->getForm(); + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php new file mode 100644 index 00000000..cd5561a2 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionTest.php @@ -0,0 +1,118 @@ +extension = new PayPlugGatewayConfigurationTypeExtension($this->createMock(TranslatorInterface::class)); + } + + public function testBuildForm_addsOneClickCheckboxField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[0]; + self::assertSame(PayPlugGatewayFactory::ONE_CLICK, $name); + self::assertSame(CheckboxType::class, $type); + self::assertSame('payplug_sylius_payplug_plugin.form.one_click_enable', $options['label']); + self::assertFalse($options['required']); + } + + public function testBuildForm_addsDeferredCaptureCheckboxField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[1]; + self::assertSame(PayPlugGatewayFactory::DEFERRED_CAPTURE, $name); + self::assertSame(CheckboxType::class, $type); + self::assertSame('payplug_sylius_payplug_plugin.form.deferred_capture_enable', $options['label']); + self::assertFalse($options['required']); + } + + public function testBuildForm_addsDisplayModeChoiceField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[2]; + self::assertSame(PayPlugGatewayFactory::DISPLAY_MODE_FIELD, $name); + self::assertSame(ChoiceType::class, $type); + self::assertFalse($options['mapped']); + self::assertFalse($options['required']); + self::assertTrue($options['expanded']); + self::assertSame( + [ + 'payplug_sylius_payplug_plugin.form.integrated_payment_enable' => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + 'payplug_sylius_payplug_plugin.ui.hosted_fields_option' => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + ], + $options['choices'], + ); + } + + public function testBuildForm_addsHfIdentifierTextField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[3]; + self::assertSame(PayPlugGatewayFactory::HF_IDENTIFIER, $name); + self::assertSame(TextType::class, $type); + self::assertSame('payplug_sylius_payplug_plugin.ui.hf_identifier_label', $options['label']); + self::assertFalse($options['required']); + } + + /** + * The SubMerchant ID field was removed once UPC made `submerchantExternalId` optional: only the + * EUR MID configurations carry a submerchant, and Hosted Fields here targets the multi-currency + * ones, so the key is omitted from every payload rather than sent empty. HF_IDENTIFIER is the + * last field added. + */ + public function testBuildForm_addsNoFieldAfterHfIdentifier(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + self::assertArrayNotHasKey(4, $addCalls); + } + + public function testGetExtendedTypes_returnsPayPlugGatewayConfigurationType(): void + { + self::assertSame([PayPlugGatewayConfigurationType::class], PayPlugGatewayConfigurationTypeExtension::getExtendedTypes()); + } + + /** + * @return array{0: FormBuilderInterface, 1: array}>} + */ + private function buildFormAndCollectAddCalls(): array + { + $builder = $this->createMock(FormBuilderInterface::class); + + $addCalls = []; + $builder + ->method('add') + ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) { + $addCalls[] = [$name, $type, $options]; + + return $builder; + }) + ; + $builder->method('addEventListener')->willReturn($builder); + + $this->extension->buildForm($builder, []); + + return [$builder, $addCalls]; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php new file mode 100644 index 00000000..b470fe11 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionFormSubmissionTest.php @@ -0,0 +1,280 @@ +add('channels', ChannelChoiceType::class, $alreadyResolvedOptions + [...])` + * survives a *second* trip through ChannelChoiceType's real OptionsResolver and its normalizers; + * 2. that an error added to the `channels` child stays there instead of bubbling to the root + * (ChoiceType sets `error_bubbling => false`, unlike a compound FormType); + * 3. that `Form::add()` from inside POST_SET_DATA really re-maps the model data into the replaced + * child, rather than leaving it empty. + * + * All three are facts about Symfony, not about this plugin, so they are exactly the kind of thing + * that changes under a minor bump without any of our own code moving. + * + * The root form here is assembled by hand rather than through Sylius' own PaymentMethodType, whose + * constructor drags in the translations tree, GatewayConfigType and two generators. What it does + * reproduce faithfully is the shape this extension reaches into: a root carrying `enabled`, a real + * `channels` ChannelChoiceType and a `gatewayConfig.config` subtree, with the extension's listeners + * attached to the real root builder. + */ +final class PaymentMethodTypeExtensionFormSubmissionTest extends TypeTestCase +{ + private const CONFLICT_MESSAGE_ID = 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict'; + + private ChannelInterface $channelFr; + + private ChannelInterface $channelIt; + + /** @var PaymentMethodRepositoryInterface&MockObject */ + private PaymentMethodRepositoryInterface $paymentMethodRepository; + + protected function setUp(): void + { + $this->channelFr = $this->buildChannel('WEB_FR', 'EUR'); + $this->channelIt = $this->buildChannel('WEB_IT', 'EUR'); + $this->paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + + parent::setUp(); + } + + protected function getTypes(): array + { + $channelRepository = $this->createMock(RepositoryInterface::class); + $channelRepository->method('findAll')->willReturn([$this->channelFr, $this->channelIt]); + + return [ + new ChannelChoiceType($channelRepository), + new PayPlugGatewayConfigurationType($this->translator()), + ]; + } + + /** + * A channel another *enabled* CB gateway already holds must come back as a submit-time error on + * the `channels` field itself — not bubbled to the root, where the admin form would render it + * detached from the field that caused it. + */ + public function testSubmit_channelClaimedByAnotherEnabledGateway_isRejectedOnTheChannelsField(): void + { + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $form->submit([ + 'enabled' => '1', + 'channels' => ['WEB_FR'], + ]); + + self::assertTrue($form->isSubmitted()); + self::assertFalse($form->isValid()); + + $channelsErrors = $form->get('channels')->getErrors(); + self::assertCount(1, $channelsErrors); + self::assertSame( + self::CONFLICT_MESSAGE_ID . '|WEB_FR|CB — France', + $channelsErrors[0]->getMessage(), + ); + + self::assertCount( + 0, + $form->getErrors(), + 'The conflict must stay on the channels field — ChoiceType does not bubble.', + ); + } + + /** + * The mirror case: a free channel submits cleanly. Guards against the rule firing on everything. + */ + public function testSubmit_channelHeldByNobody_isAccepted(): void + { + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $form->submit([ + 'enabled' => '1', + 'channels' => ['WEB_IT'], + ]); + + self::assertTrue($form->isValid(), 'WEB_IT is held by nobody, so the submission must pass.'); + self::assertCount(0, $form->get('channels')->getErrors(true)); + } + + /** + * PRE-3629, through the real ChannelChoiceType: the replaced `channels` child must still render + * both choices — proving the resolved options survived the second pass through the resolver — + * with only the claimed one carrying `disabled` and a `title` naming the claiming method. + */ + public function testSetData_channelClaimedByAnotherEnabledGateway_isRenderedDisabled(): void + { + $this->givenRivalGatewayHolding($this->channelIt, 'CB — Italy'); + + $form = $this->createRootForm($this->payPlugPaymentMethod()); + + $channelChoices = $form->get('channels')->createView()->children; + + self::assertCount(2, $channelChoices, 'Both channels must still be offered.'); + + $byValue = []; + foreach ($channelChoices as $choiceView) { + $byValue[$choiceView->vars['value']] = $choiceView->vars['attr']; + } + + self::assertArrayNotHasKey('disabled', $byValue['WEB_FR'], 'WEB_FR is free and must stay selectable.'); + self::assertTrue($byValue['WEB_IT']['disabled'] ?? false, 'WEB_IT is claimed and must be disabled.'); + self::assertSame( + self::CONFLICT_MESSAGE_ID . '|WEB_IT|CB — Italy', + $byValue['WEB_IT']['title'] ?? null, + ); + } + + /** + * Channels the edited method already holds stay selectable even when the checker would report + * them: browsers do not submit disabled checkboxes, so disabling a *checked* one would silently + * drop the channel on save. This is the render-side half of that rule, exercised against the + * real ChoiceType rather than a stub. + */ + public function testSetData_channelAlreadyHeldByTheEditedMethod_staysSelectable(): void + { + $subject = $this->payPlugPaymentMethod(); + $subject->addChannel($this->channelFr); + + $this->givenRivalGatewayHolding($this->channelFr, 'CB — France'); + + $form = $this->createRootForm($subject); + + foreach ($form->get('channels')->createView()->children as $choiceView) { + if ('WEB_FR' === $choiceView->vars['value']) { + self::assertArrayNotHasKey( + 'disabled', + $choiceView->vars['attr'], + 'A channel the edited method already holds must stay selectable.', + ); + } + } + } + + /** + * Rivals are what `findEnabledByGatewayName()` returns, so stubbing that one method is enough to + * drive the real GatewayChannelConflictChecker. + */ + private function givenRivalGatewayHolding(ChannelInterface $channel, string $name): void + { + $rival = $this->createMock(PaymentMethodInterface::class); + $rival->method('getId')->willReturn(99); + $rival->method('getName')->willReturn($name); + $rival->method('isEnabled')->willReturn(true); + $rival->method('getChannels')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([$channel])); + + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn([$rival]); + } + + private function payPlugPaymentMethod(): PaymentMethod + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setGatewayName(PayPlugGatewayFactory::FACTORY_NAME); + // Redirected mode: neither display-mode flag set. The CB type only gates on base currency + // for integrated payment, so this keeps these tests about the channel-conflict rule alone. + $gatewayConfig->setConfig([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + PayPlugGatewayFactory::HOSTED_FIELDS => false, + ]); + + $paymentMethod = new PaymentMethod(); + $paymentMethod->setCode('payplug_cb'); + $paymentMethod->setGatewayConfig($gatewayConfig); + + return $paymentMethod; + } + + /** + * The shape `PaymentMethodTypeExtension` reaches into: `enabled`, a real `channels` + * ChannelChoiceType, and `gatewayConfig.config` — with the extension's own listeners attached + * to the real root builder, so they run through Symfony's real event dispatch. + */ + private function createRootForm(PaymentMethod $paymentMethod): FormInterface + { + $extension = new PaymentMethodTypeExtension( + new GatewayChannelConflictChecker($this->paymentMethodRepository), + $this->translator(), + ); + + $builder = $this->factory->createBuilder(FormType::class, $paymentMethod, [ + 'data_class' => PaymentMethod::class, + ]); + + $builder->add('enabled', CheckboxType::class, ['required' => false]); + $builder->add('channels', ChannelChoiceType::class, [ + 'multiple' => true, + 'expanded' => true, + ]); + + $gatewayConfig = $builder->create('gatewayConfig', FormType::class, ['mapped' => false]); + $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class, ['mapped' => false]); + $builder->add($gatewayConfig); + + $extension->buildForm($builder, []); + + return $builder->getForm(); + } + + private function buildChannel(string $code, string $currencyCode): ChannelInterface + { + $currency = new Currency(); + $currency->setCode($currencyCode); + + $channel = new Channel(); + $channel->setCode($code); + $channel->setName($code); + $channel->setBaseCurrency($currency); + + return $channel; + } + + private function translator(): TranslatorInterface + { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + static fn (string $id, array $parameters = []): string => \implode( + '|', + [$id, ...\array_values($parameters)], + ), + ); + + return $translator; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php new file mode 100644 index 00000000..482a02ec --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PaymentMethodTypeExtensionTest.php @@ -0,0 +1,352 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + } + + /** + * PRE-3553: Integrated Payment is EUR-only, and the error must land on the `channels` field. + */ + public function testPostSubmit_integratedPaymentOnNonEurChannel_addsCurrencyErrorOnChannels(): void + { + self::assertSame( + ['payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible'], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true], 'USD'), + ); + } + + public function testPostSubmit_integratedPaymentOnEuroChannel_addsNoError(): void + { + self::assertSame( + [], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true], 'EUR'), + ); + } + + /** + * The CB gate narrows the check to Integrated Payment: Hosted Fields works in any currency. + */ + public function testPostSubmit_hostedFieldsOnNonEurChannel_addsNoError(): void + { + self::assertSame( + [], + $this->submitPayPlugPaymentMethod([PayPlugGatewayFactory::HOSTED_FIELDS => true], 'USD'), + ); + } + + /** + * This extension must target the base `PaymentMethodType`, not the AdminBundle subtype, so that + * the AdminBundle form (which extends the base type) inherits the listener too. + */ + public function testGetExtendedTypes_returnsPaymentMethodType(): void + { + self::assertSame([PaymentMethodType::class], PaymentMethodTypeExtension::getExtendedTypes()); + } + + /** + * PRE-3629: a channel held by another *enabled* CB gateway renders as an unselectable choice, + * with the same wording the POST_SUBMIT rule would have produced had it been submitted. + */ + public function testPostSetData_channelClaimedByAnotherEnabledGateway_isRenderedDisabled(): void + { + $choiceAttr = $this->rebuiltChannelsOptions()['choice_attr']; + + self::assertSame( + [ + 'disabled' => true, + 'title' => 'payplug_sylius_payplug_plugin.form.gateway_channel_conflict|WEB_FR|CB 1', + ], + $choiceAttr($this->channel('WEB_FR')), + ); + } + + public function testPostSetData_unclaimedChannel_staysSelectable(): void + { + $choiceAttr = $this->rebuiltChannelsOptions()['choice_attr']; + + self::assertSame([], $choiceAttr($this->channel('WEB_IT'))); + } + + /** + * The field is replaced rather than configured in place, so everything Sylius put on it — + * `multiple`, `expanded`, its label — has to survive the round trip. + */ + public function testPostSetData_rebuiltChannelsField_keepsItsOriginalOptions(): void + { + $options = $this->rebuiltChannelsOptions(); + + self::assertTrue($options['multiple']); + self::assertTrue($options['expanded']); + self::assertSame('sylius.form.payment_method.channels', $options['label']); + } + + /** + * Payment methods belonging to other plugins share this form; their channel picker is none of + * our business. + */ + public function testPostSetData_paymentMethodIsNotOneOfOurs_leavesTheChannelsFieldUntouched(): void + { + self::assertNull($this->runPostSetData(new \stdClass())); + } + + /** + * Runs the extension's POST_SET_DATA listener over a CB payment method, against a rival CB + * gateway that is enabled and holds `WEB_FR`. + * + * @return array the options handed to `add('channels', ...)` + */ + private function rebuiltChannelsOptions(): array + { + $captured = $this->runPostSetData($this->payPlugPaymentMethod()); + + self::assertIsArray($captured); + self::assertSame('channels', $captured['name']); + self::assertSame(ChannelChoiceType::class, $captured['type']); + self::assertIsCallable($captured['options']['choice_attr']); + + return $captured['options']; + } + + /** + * @return array{name: string, type: string, options: array}|null + * the `add()` call the listener made, or null when it left the form alone + */ + private function runPostSetData(mixed $data): ?array + { + $rival = $this->createMock(PaymentMethodInterface::class); + $rival->method('getId')->willReturn(7); + $rival->method('isEnabled')->willReturn(true); + $rival->method('getName')->willReturn('CB 1'); + $rival->method('getChannels')->willReturn(new ArrayCollection([$this->channel('WEB_FR')])); + + $repository = $this->createMock(PaymentMethodRepositoryInterface::class); + $repository->method('findEnabledByGatewayName')->willReturn([$rival]); + + $captured = null; + $form = $this->buildRootForm( + $this->channelsForm(), + function (string $name, string $type, array $options) use (&$captured): void { + $captured = ['name' => $name, 'type' => $type, 'options' => $options]; + }, + ); + + // Renders the parameters into the result so the assertions can pin *which* channel and + // which rival payment method the tooltip names, not just that some message was translated. + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + static fn (string $id, array $parameters = []): string => \implode( + '|', + [$id, ...\array_values($parameters)], + ), + ); + + ($this->captureListener(FormEvents::POST_SET_DATA, $repository, $translator))(new FormEvent($form, $data)); + + return $captured; + } + + /** + * Stand-in for the `channels` field Sylius' own CoreBundle extension added, carrying the + * resolved options the listener has to copy forward. + */ + private function channelsForm(): FormInterface + { + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getOptions')->willReturn([ + 'multiple' => true, + 'expanded' => true, + 'label' => 'sylius.form.payment_method.channels', + 'choice_attr' => null, + ]); + + $channelsForm = $this->createMock(FormInterface::class); + $channelsForm->method('getConfig')->willReturn($formConfig); + + return $channelsForm; + } + + private function payPlugPaymentMethod(): PaymentMethodInterface + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn(null); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection()); + + return $paymentMethod; + } + + private function channel(string $code): ChannelInterface + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($code); + + return $channel; + } + + /** + * Runs the extension's POST_SUBMIT listener over a CB payment method carrying a single channel. + * + * @param array $mappedGatewayConfig as returned by GatewayConfigInterface::getConfig() + * + * @return list messages of the FormErrors added to the `channels` child + */ + private function submitPayPlugPaymentMethod(array $mappedGatewayConfig, string $baseCurrencyCode): array + { + $currency = $this->createMock(CurrencyInterface::class); + $currency->method('getCode')->willReturn($baseCurrencyCode); + + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn('channel_code'); + $channel->method('getBaseCurrency')->willReturn($currency); + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn($mappedGatewayConfig); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel])); + + $errors = []; + $channelsForm = $this->createMock(FormInterface::class); + $channelsForm->method('addError')->willReturnCallback( + function (FormError $error) use (&$errors, $channelsForm): FormInterface { + $errors[] = $error->getMessage(); + + return $channelsForm; + }, + ); + + $repository = $this->createMock(PaymentMethodRepositoryInterface::class); + $repository->method('findEnabledByGatewayName')->willReturn([]); + + ($this->captureListener(FormEvents::POST_SUBMIT, $repository))( + new FormEvent($this->buildRootForm($channelsForm), $paymentMethod), + ); + + return $errors; + } + + /** + * Minimal stand-in for the `paymentMethod` → `gatewayConfig` → `config` form tree the extension + * walks to resolve the gateway's configuration type. + */ + private function buildRootForm(FormInterface $channelsForm, ?callable $onAdd = null): FormInterface + { + $resolvedType = $this->createMock(ResolvedFormTypeInterface::class); + $resolvedType->method('getInnerType')->willReturn(new PayPlugGatewayConfigurationType($this->translator)); + + $formConfig = $this->createMock(FormConfigInterface::class); + $formConfig->method('getType')->willReturn($resolvedType); + + $configForm = $this->createMock(FormInterface::class); + $configForm->method('getConfig')->willReturn($formConfig); + + $gatewayConfigForm = $this->createMock(FormInterface::class); + $gatewayConfigForm->method('has')->willReturnCallback(static fn (string $name): bool => 'config' === $name); + $gatewayConfigForm->method('get')->willReturn($configForm); + + $form = $this->createMock(FormInterface::class); + $form->method('has')->willReturnCallback( + static fn (string $name): bool => \in_array($name, ['gatewayConfig', 'channels'], true), + ); + $form->method('get')->willReturnCallback( + static fn (string $name): FormInterface => 'channels' === $name ? $channelsForm : $gatewayConfigForm, + ); + $form->method('add')->willReturnCallback( + function (string $name, string $type, array $options) use ($onAdd, $form): FormInterface { + if (null !== $onAdd) { + $onAdd($name, $type, $options); + } + + return $form; + }, + ); + + return $form; + } + + private function captureListener( + string $wantedEventName, + PaymentMethodRepositoryInterface $repository, + ?TranslatorInterface $translator = null, + ): callable { + $extension = new PaymentMethodTypeExtension( + new GatewayChannelConflictChecker($repository), + $translator ?? $this->translator, + ); + + $listener = null; + $builder = $this->createMock(FormBuilderInterface::class); + $builder->method('addEventListener')->willReturnCallback( + function (string $eventName, callable $callback) use ( + $wantedEventName, + &$listener, + $builder, + ): FormBuilderInterface { + if ($wantedEventName === $eventName) { + $listener = $callback; + } + + return $builder; + }, + ); + + $extension->buildForm($builder, []); + + self::assertIsCallable($listener); + + return $listener; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php b/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php new file mode 100644 index 00000000..5e5f636d --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/ScalapayGatewayConfigurationTypeExtensionTest.php @@ -0,0 +1,71 @@ +extension = new ScalapayGatewayConfigurationTypeExtension(); + } + + public function testBuildForm_addsMinAmountMoneyField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[0]; + self::assertSame(ScalapayGatewayFactory::MIN_AMOUNT, $name); + self::assertSame(MoneyType::class, $type); + self::assertSame('EUR', $options['currency']); + self::assertFalse($options['required']); + } + + public function testBuildForm_addsMaxAmountMoneyField(): void + { + [, $addCalls] = $this->buildFormAndCollectAddCalls(); + + [$name, $type, $options] = $addCalls[1]; + self::assertSame(ScalapayGatewayFactory::MAX_AMOUNT, $name); + self::assertSame(MoneyType::class, $type); + self::assertSame('EUR', $options['currency']); + self::assertFalse($options['required']); + } + + public function testGetExtendedTypes_returnsScalapayGatewayConfigurationType(): void + { + self::assertSame([ScalapayGatewayConfigurationType::class], ScalapayGatewayConfigurationTypeExtension::getExtendedTypes()); + } + + /** + * @return array{0: FormBuilderInterface, 1: array}>} + */ + private function buildFormAndCollectAddCalls(): array + { + $builder = $this->createMock(FormBuilderInterface::class); + + $addCalls = []; + $builder + ->method('add') + ->willReturnCallback(function ($name, $type = null, array $options = []) use (&$addCalls, $builder) { + $addCalls[] = [$name, $type, $options]; + + return $builder; + }) + ; + + $this->extension->buildForm($builder, []); + + return [$builder, $addCalls]; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php new file mode 100644 index 00000000..1f8eb048 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php @@ -0,0 +1,90 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + $this->type = new AbstractGatewayConfigurationType( + $this->translator, + ); + } + + /** + * Default hook implementation: every gateway subtype that doesn't override it keeps + * today's behavior of always enforcing the base currency. + */ + public function testShouldValidateBaseCurrency_defaultImplementation_alwaysReturnsTrue(): void + { + self::assertTrue($this->type->shouldValidateBaseCurrency([])); + self::assertTrue($this->type->shouldValidateBaseCurrency(['anything' => 'irrelevant'])); + } + + /** + * Default hook implementation: every gateway subtype that doesn't override it keeps today's + * generic per-gateway wording (only `PayPlugGatewayConfigurationType` overrides this, for a + * message specific to Integrated Payment). + */ + public function testBaseCurrencyViolationMessage_defaultImplementation_returnsGenericKey(): void + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn('channel_code'); + + self::assertSame( + 'payplug_sylius_payplug_plugin.form.base_currency_not_euro', + $this->type->baseCurrencyViolationMessage($channel), + ); + } + + public function testGetBaseCurrencyCode_defaultImplementation_isEuro(): void + { + self::assertSame('EUR', $this->type->getBaseCurrencyCode()); + } + + /** + * The only subtype that narrows the hook: Integrated Payment is the only display mode that + * requires every associated channel to be EUR. + * + * Keyed by the persisted `integratedPayment`/`hostedFields` booleans, which is what the caller + * passes (`GatewayConfigInterface::getConfig()`); the `hostedFieldsMode` form field is unmapped + * and never appears in that array. + */ + public function testShouldValidateBaseCurrency_payPlugType_onlyAppliesToIntegratedPayment(): void + { + $type = new PayPlugGatewayConfigurationType( + $this->translator, + ); + + self::assertTrue($type->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + ])); + self::assertFalse($type->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ])); + self::assertFalse($type->shouldValidateBaseCurrency([])); + } +} diff --git a/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php new file mode 100644 index 00000000..8edacc1e --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php @@ -0,0 +1,92 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + $this->type = new PayPlugGatewayConfigurationType( + $this->translator, + ); + } + + /** + * The arrays below are keyed by the persisted `integratedPayment`/`hostedFields` booleans + * written by `PayPlugGatewayFactory::resolveDisplayModeFlags()`, i.e. the shape the production + * caller hands the hook (`GatewayConfigInterface::getConfig()`). The `hostedFieldsMode` form + * field is unmapped and never reaches the persisted config. + */ + public function testShouldValidateBaseCurrency_integratedPaymentSelected_returnsTrue(): void + { + self::assertTrue($this->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + ])); + } + + public function testShouldValidateBaseCurrency_hostedFieldsSelected_returnsFalse(): void + { + self::assertFalse($this->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ])); + } + + public function testShouldValidateBaseCurrency_noModeSelected_returnsFalse(): void + { + self::assertFalse($this->shouldValidateBaseCurrency([])); + } + + /** + * PRE-3553: this must be a message specific to Integrated Payment, not the generic + * `base_currency_not_euro` wording used by every other gateway subtype - since + * shouldValidateBaseCurrency() above only lets this fire when integrated_payment is + * selected, it doesn't need to branch on mode itself. + */ + public function testBaseCurrencyViolationMessage_returnsIntegratedPaymentSpecificKey(): void + { + $channel = $this->createMock(ChannelInterface::class); + + self::assertSame( + 'payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible', + $this->baseCurrencyViolationMessage($channel), + ); + } + + private function shouldValidateBaseCurrency(array $data): bool + { + $method = new \ReflectionMethod(PayPlugGatewayConfigurationType::class, 'shouldValidateBaseCurrency'); + $method->setAccessible(true); + + /** @var bool $result */ + $result = $method->invoke($this->type, $data); + + return $result; + } + + private function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + $method = new \ReflectionMethod(PayPlugGatewayConfigurationType::class, 'baseCurrencyViolationMessage'); + $method->setAccessible(true); + + /** @var string $result */ + $result = $method->invoke($this->type, $channel); + + return $result; + } +} diff --git a/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php new file mode 100644 index 00000000..7a1ae459 --- /dev/null +++ b/tests/PHPUnit/Gateway/PayPlugGatewayFactoryTest.php @@ -0,0 +1,131 @@ + true]), + ); + } + + public function testResolveDisplayMode_hostedFieldsTrueAndIntegratedPaymentTrue_hostedFieldsWins(): void + { + self::assertSame( + PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::resolveDisplayMode([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, + ]), + ); + } + + public function testResolveDisplayMode_integratedPaymentTrue_returnsIntegratedPayment(): void + { + self::assertSame( + PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + PayPlugGatewayFactory::resolveDisplayMode([PayPlugGatewayFactory::INTEGRATED_PAYMENT => true]), + ); + } + + public function testResolveDisplayMode_neitherFlagSet_returnsNull(): void + { + self::assertNull(PayPlugGatewayFactory::resolveDisplayMode([])); + } + + public function testResolveDisplayMode_bothFlagsFalse_returnsNull(): void + { + self::assertNull(PayPlugGatewayFactory::resolveDisplayMode([ + PayPlugGatewayFactory::HOSTED_FIELDS => false, + PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, + ])); + } + + // ------------------------------------------------------------------------- + // resolveDisplayModeFlags() + // ------------------------------------------------------------------------- + + public function testResolveDisplayModeFlags_hostedFields_setsHostedFieldsOnlyTrue(): void + { + self::assertSame( + [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => true], + PayPlugGatewayFactory::resolveDisplayModeFlags(PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS), + ); + } + + public function testResolveDisplayModeFlags_integratedPayment_setsIntegratedPaymentOnlyTrue(): void + { + self::assertSame( + [PayPlugGatewayFactory::INTEGRATED_PAYMENT => true, PayPlugGatewayFactory::HOSTED_FIELDS => false], + PayPlugGatewayFactory::resolveDisplayModeFlags(PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT), + ); + } + + public function testResolveDisplayModeFlags_null_setsBothFalse(): void + { + self::assertSame( + [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => false], + PayPlugGatewayFactory::resolveDisplayModeFlags(null), + ); + } + + public function testResolveDisplayModeFlags_unknownValue_setsBothFalse(): void + { + self::assertSame( + [PayPlugGatewayFactory::INTEGRATED_PAYMENT => false, PayPlugGatewayFactory::HOSTED_FIELDS => false], + PayPlugGatewayFactory::resolveDisplayModeFlags('not_a_real_mode'), + ); + } + + // ------------------------------------------------------------------------- + // missingHostedFieldsRequirements() + // ------------------------------------------------------------------------- + + public function testMissingHostedFieldsRequirements_hostedFieldsNotSelected_returnsEmpty(): void + { + self::assertSame([], PayPlugGatewayFactory::missingHostedFieldsRequirements([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + ])); + } + + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierMissing_returnsIdentifier(): void + { + self::assertSame( + [PayPlugGatewayFactory::HF_IDENTIFIER], + PayPlugGatewayFactory::missingHostedFieldsRequirements([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + ]), + ); + } + + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierBlank_returnsIdentifier(): void + { + self::assertSame( + [PayPlugGatewayFactory::HF_IDENTIFIER], + PayPlugGatewayFactory::missingHostedFieldsRequirements([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HF_IDENTIFIER => ' ', + ]), + ); + } + + public function testMissingHostedFieldsRequirements_hostedFieldsSelectedIdentifierFilled_returnsEmpty(): void + { + self::assertSame([], PayPlugGatewayFactory::missingHostedFieldsRequirements([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ])); + } +} diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php new file mode 100644 index 00000000..811add4e --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/HasNoGatewayChannelConflictValidatorTest.php @@ -0,0 +1,144 @@ +paymentMethodRepository = $this->createMock(PaymentMethodRepositoryInterface::class); + + return new HasNoGatewayChannelConflictValidator( + new GatewayChannelConflictChecker($this->paymentMethodRepository), + ); + } + + /** + * The point of the constraint: the same rule the admin form enforces, reached through + * PaymentMethodValidator, so a resource-controller or API write cannot leave two enabled + * gateways of one factory sharing a channel. + */ + public function testValidate_channelClaimedByAnotherEnabledGateway_addsAViolationOnChannels(): void + { + $channel = $this->channel('WEB_FR'); + + $this->givenEnabledGateways([ + $this->paymentMethod(id: 7, channels: [$channel], name: 'CB — France'), + ]); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$channel]), + new HasNoGatewayChannelConflict(), + ); + + $this->buildViolation('payplug_sylius_payplug_plugin.form.gateway_channel_conflict') + ->setParameter('%channel%', 'WEB_FR') + ->setParameter('%payment_method%', 'CB — France') + ->atPath('property.path.channels') + ->assertRaised(); + } + + public function testValidate_disjointChannels_addsNoViolation(): void + { + $this->givenEnabledGateways([ + $this->paymentMethod(id: 7, channels: [$this->channel('WEB_FR')], name: 'CB — France'), + ]); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$this->channel('WEB_IT')]), + new HasNoGatewayChannelConflict(), + ); + + $this->assertNoViolation(); + } + + /** + * A disabled subject claims nothing — that is what makes logging a gateway out release its + * channels for another config (PRE-3632). The repository must not even be consulted. + */ + public function testValidate_disabledSubject_isNotEvenChecked(): void + { + $this->paymentMethodRepository->expects(self::never())->method('findEnabledByGatewayName'); + + $this->validator->validate( + $this->paymentMethod(id: 8, channels: [$this->channel('WEB_FR')], enabled: false), + new HasNoGatewayChannelConflict(), + ); + + $this->assertNoViolation(); + } + + public function testValidate_aValueThatIsNotAPaymentMethod_addsNoViolation(): void + { + $this->validator->validate('not a payment method', new HasNoGatewayChannelConflict()); + + $this->assertNoViolation(); + } + + public function testValidate_theWrongConstraint_throws(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->validator->validate($this->paymentMethod(id: 8, channels: []), new NotBlank()); + } + + /** + * @param list $paymentMethods + */ + private function givenEnabledGateways(array $paymentMethods): void + { + $this->paymentMethodRepository + ->method('findEnabledByGatewayName') + ->with(PayPlugGatewayFactory::FACTORY_NAME) + ->willReturn($paymentMethods); + } + + /** + * @param list $channels + */ + private function paymentMethod( + int $id, + array $channels, + string $name = 'CB', + bool $enabled = true, + ): PaymentMethodInterface&MockObject { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getId')->willReturn($id); + $paymentMethod->method('getName')->willReturn($name); + $paymentMethod->method('isEnabled')->willReturn($enabled); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection($channels)); + + return $paymentMethod; + } + + private function channel(string $code): ChannelInterface&MockObject + { + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn($code); + + return $channel; + } +} diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php new file mode 100644 index 00000000..782ad9c6 --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsCanSavePaymentMethodValidatorTest.php @@ -0,0 +1,136 @@ +apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + return new IsCanSavePaymentMethodValidator($this->apiClientFactory); + } + + /** + * @dataProvider skipListedFactoryProvider + */ + public function testValidate_skipListedFactory_noViolationAndAccountNeverInspected(string $factoryName): void + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->expects(self::never())->method('getAccount'); + + $this->apiClientFactory + ->expects(self::once()) + ->method('createForPaymentMethod') + ->willReturn($apiClient) + ; + + $this->validator->validate($this->buildPaymentMethod($factoryName), new IsCanSavePaymentMethod()); + + $this->assertNoViolation(); + } + + /** + * @return iterable + */ + public static function skipListedFactoryProvider(): iterable + { + yield 'payplug' => [PayPlugGatewayFactory::FACTORY_NAME]; + yield 'payplug_oney' => [OneyGatewayFactory::FACTORY_NAME]; + } + + public function testValidate_nonSkipListedFactory_notEnabledOnAccount_raisesNoAccessViolation(): void + { + $apiClient = $this->mockApiClientWithAccount([ + 'is_live' => true, + 'payment_methods' => [ + 'scalapay' => ['enabled' => false], + ], + ]); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $constraint = new IsCanSavePaymentMethod(); + $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint); + + $this->buildViolation(sprintf($constraint->noAccessMessage, ScalapayGatewayFactory::FACTORY_NAME)) + ->assertRaised() + ; + } + + public function testValidate_nonSkipListedFactory_enabledButNotLive_raisesNoTestKeyViolation(): void + { + $apiClient = $this->mockApiClientWithAccount([ + 'is_live' => false, + 'payment_methods' => [ + 'scalapay' => ['enabled' => true], + ], + ]); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $constraint = new IsCanSavePaymentMethod(); + $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), $constraint); + + $this->buildViolation(sprintf($constraint->noTestKeyMessage, ScalapayGatewayFactory::FACTORY_NAME)) + ->assertRaised() + ; + } + + public function testValidate_nonSkipListedFactory_enabledAndLive_noViolation(): void + { + $apiClient = $this->mockApiClientWithAccount([ + 'is_live' => true, + 'payment_methods' => [ + 'scalapay' => ['enabled' => true], + ], + ]); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $this->validator->validate($this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME), new IsCanSavePaymentMethod()); + + $this->assertNoViolation(); + } + + private function mockApiClientWithAccount(array $account): PayPlugApiClientInterface&MockObject + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willReturn($account); + + return $apiClient; + } + + private function buildPaymentMethod(string $factoryName): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('isEnabled')->willReturn(true); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('getChannels')->willReturn(new ArrayCollection()); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php b/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php new file mode 100644 index 00000000..3574934f --- /dev/null +++ b/tests/PHPUnit/Gateway/Validator/Constraints/IsScalapayAmountRangeValidValidatorTest.php @@ -0,0 +1,255 @@ +apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + return new IsScalapayAmountRangeValidValidator($this->apiClientFactory, new AccountAmountRangeResolver(), $this->logger); + } + + public function testValidate_nonScalapayFactory_noViolationAndApiNeverCalled(): void + { + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $paymentMethod = $this->buildPaymentMethod(OneyGatewayFactory::FACTORY_NAME, []); + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + public function testValidate_noAmountsConfigured_noViolationAndApiNeverCalled(): void + { + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + public function testValidate_minGreaterThanMax_raisesViolationWithoutCallingApi(): void + { + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 5000, ScalapayGatewayFactory::MAX_AMOUNT => 1000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $constraint = new IsScalapayAmountRangeValid(); + $this->validator->validate($paymentMethod, $constraint); + + $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised(); + } + + public function testValidate_minBelowApiMin_raisesOutOfRangeViolation(): void + { + $apiClient = $this->mockApiClientWithAccount(500, 200000); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 100]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $constraint = new IsScalapayAmountRangeValid(); + $this->validator->validate($paymentMethod, $constraint); + + $this->buildViolation($constraint->outOfRangeMessage) + ->setParameter('%min_amount%', '5.00') + ->setParameter('%max_amount%', '2000.00') + ->assertRaised() + ; + } + + public function testValidate_maxAboveApiMax_raisesOutOfRangeViolation(): void + { + $apiClient = $this->mockApiClientWithAccount(500, 200000); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $constraint = new IsScalapayAmountRangeValid(); + $this->validator->validate($paymentMethod, $constraint); + + $this->buildViolation($constraint->outOfRangeMessage) + ->setParameter('%min_amount%', '5.00') + ->setParameter('%max_amount%', '2000.00') + ->assertRaised() + ; + } + + public function testValidate_withinApiBounds_noViolation(): void + { + $apiClient = $this->mockApiClientWithAccount(500, 200000); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + public function testValidate_apiThrowsUnauthorizedException_noViolation(): void + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willThrowException(new UnauthorizedException('unauthorized')); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + // Failing open leaves the range unvalidated, so the skip must at least be traceable. + $this->logger->expects(self::once())->method('warning'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + public function testValidate_apiThrowsConnectionException_noViolation(): void + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willThrowException(new ConnectionException('network blip')); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $this->logger->expects(self::once())->method('warning'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + /** + * The account authorizes no EUR range for Scalapay at all, so there is nothing to check the + * configured range against. Same fail-open outcome as an API error, and logged for the same + * reason. + */ + public function testValidate_accountHasNoEurRange_noViolationButLogged(): void + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willReturn([ + 'configuration' => ['min_amounts' => ['USD' => 500], 'max_amounts' => ['USD' => 200000]], + 'payment_methods' => [], + ]); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $this->logger->expects(self::once())->method('warning'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + /** + * The gateway config is a plain serialized array, so a direct DB edit or an import script can + * leave a non-int in it. PaymentMethodValidator::process() has no try/catch: a malformed value + * must degrade to "not configured" rather than 500 the admin save with an assertion error. + */ + public function testValidate_malformedConfiguredAmounts_noViolationButLogged(): void + { + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + $this->logger->expects(self::once())->method('warning'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => '1000', ScalapayGatewayFactory::MAX_AMOUNT => 'nonsense']; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + public function testValidate_disabledMethod_noViolationAndApiNeverCalled(): void + { + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $config = [ScalapayGatewayFactory::MIN_AMOUNT => 1000, ScalapayGatewayFactory::MAX_AMOUNT => 100000]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config, false); + + $this->validator->validate($paymentMethod, new IsScalapayAmountRangeValid()); + + $this->assertNoViolation(); + } + + /** + * API range: min=500, max=200000 (cents). Merchant sets only max_amount=300, leaving + * min_amount blank. At checkout, the blank side falls back to the API bound (500), making + * the *effective* range inverted (500 > 300) even though neither configured value alone + * looks invalid against its own matching API bound. + */ + public function testValidate_onlyMaxConfiguredBelowEffectiveMin_raisesMinGreaterThanMaxViolation(): void + { + $apiClient = $this->mockApiClientWithAccount(500, 200000); + $this->apiClientFactory->method('createForPaymentMethod')->willReturn($apiClient); + + $config = [ScalapayGatewayFactory::MAX_AMOUNT => 300]; + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, $config); + + $constraint = new IsScalapayAmountRangeValid(); + $this->validator->validate($paymentMethod, $constraint); + + $this->buildViolation($constraint->minGreaterThanMaxMessage)->assertRaised(); + } + + private function mockApiClientWithAccount(int $minAmount, int $maxAmount): PayPlugApiClientInterface&MockObject + { + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willReturn([ + 'configuration' => [ + 'min_amounts' => ['EUR' => $minAmount], + 'max_amounts' => ['EUR' => $maxAmount], + ], + 'payment_methods' => [], + ]); + + return $apiClient; + } + + private function buildPaymentMethod( + string $factoryName, + array $config, + bool $enabled = true, + ): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + $paymentMethod->method('isEnabled')->willReturn($enabled); + + return $paymentMethod; + } +} diff --git a/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php new file mode 100644 index 00000000..b89d3427 --- /dev/null +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -0,0 +1,744 @@ +paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + // The handler scopes before reading; these tests stub one account, so it scopes to itself. + $this->configurationRepository->method('forPaymentMethod')->willReturnSelf(); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->logger = $this->createMock(LoggerInterface::class); + $this->payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + } + + /** + * @param mixed[] $details + */ + private function payment( + int $id = 42, + int $amount = 1000, + ?string $orderNumber = null, + array $details = [], + ?PaymentMethodInterface $method = null, + ?CustomerInterface $customer = null, + ): PaymentInterface&MockObject { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn($id); + $payment->method('getAmount')->willReturn($amount); + $payment->method('getDetails')->willReturn($details); + // A UHF payment always has a method — treat() resolves the webhook secret from its gateway + // config — so the tests that don't care which one still get one. + $payment->method('getMethod')->willReturn($method ?? $this->createMock(PaymentMethodInterface::class)); + + if (null !== $orderNumber || null !== $customer) { + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn($orderNumber); + $order->method('getCustomer')->willReturn($customer); + $payment->method('getOrder')->willReturn($order); + } else { + $payment->method('getOrder')->willReturn(null); + } + + return $payment; + } + + public function testTreat_onValidNotification_savesTreatsAndAppliesTheOutcomeAgainstTheResolvedPayment(): void + { + // orderId here ("42") matches the payment id fallback used when the payment has no order + // yet — the same fallback CaptureHostedPaymentRequestHandler uses when sending orderId to + // PayPlug at creation time (order number if present, else the payment id). + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + + $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false); + + $this->lock->expects(self::once())->method('acquire')->with('payplug_upc_treat_op_123', 30)->willReturn(true); + $this->lock->expects(self::once())->method('release')->with('payplug_upc_treat_op_123'); + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * Guards the race StatusHostedPaymentRequestHandler's GET polling fallback can create: if a + * genuine webhook delivery for the same operation is already inside treat() (lock held), a + * concurrent caller must back off rather than double-apply. + */ + public function testTreat_whenLockCannotBeAcquired_doesNothingAndReturnsSilently(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + // A fresh mock rather than reconfiguring $this->lock: setUp()'s unconditional + // ->method('acquire')->willReturn(true) stub would otherwise still win over this one. + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(false); + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->lock->expects(self::never())->method('release'); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onPendingThreeDsExecCode_doesNothingAndReturnsSilently(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->lock->expects(self::never())->method('acquire'); + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * Regression for the live incident on 2026-08-21 (order 000000074): the notifier fired a + * webhook mid-3DS-challenge (execCode 0001) before the real, final one (execCode 0000). The + * premature call must not consume isTreated()'s dedupe slot, or the later, correct + * notification has nothing left to do — the payment ends up permanently stuck instead of paid. + */ + public function testTreat_onPendingExecCodeFollowedByFinalExecCode_appliesOnlyTheFinalOutcome(): void + { + $pendingBody = \json_encode(['id' => 'op_123', 'execCode' => '0001', 'orderId' => '42', 'amount' => 1000]); + $finalBody = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(false); + + $this->handler->treat($this->payment(42, 1000), $pendingBody, ['Authorization' => 'Bearer shared-secret']); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_123'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42, 1000), $finalBody, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onRealPlatformShapedNotification_stillParsesTheFourFieldsWebhookNotificationHelperNeeds(): void + { + // Captured from Datadog (staging notifier webhook attempts): the platform sends a much + // richer, nested payload than the {id, execCode, orderId, amount} shape our own tests + // otherwise use. WebhookNotificationHelper::parse() only reads those four top-level + // fields and ignores everything else, so this locks in that the extra nesting (customer, + // authentication, paymentMethod, account...) and sibling fields (paymentId, stan, + // descriptor...) never break parsing. + $body = \json_encode([ + 'operationType' => 'PAYMENT', + 'customer' => ['id' => '130', 'email' => 'test-client@example.com'], + 'authentication' => ['status' => 'Y', 'globalStatus' => 'OK', 'mode' => 'FRICTIONLESS', 'preference' => 'NO_PREF', 'version' => '2', 'enrolledCard' => 'Y'], + 'paymentMethod' => ['card' => ['bank' => 'EXAMPLE BANK', 'country' => 'GB', 'usage' => 'debit', 'code6x4' => '446421XXXXXX0000', 'type' => 'VISA', 'network' => 'VISA'], 'details' => ['validityDate' => '2030-12', 'selectedBrand' => 'VISA']], + 'account' => ['id' => 'PLUGINS_UHF_QA'], + 'additionalData' => 'Playful Paradise Cap', + 'currency' => 'EUR', + 'amount' => 7400, + 'descriptor' => 'PPG', + 'authorizationCode' => '452743', + 'bankResponse' => '00', + 'schemeTransactionId' => 'G8N6XKPB07CO5JT', + 'execCode' => '0000', + 'message' => 'Successful operation', + 'orderId' => '000000065', + 'stan' => '333446', + 'id' => 'e4d04233-a15d-4815-af91-698c3eb61c36', + 'paymentId' => 'b1dde7ce-d069-43dd-b49d-9f2f1cd9d671', + ]); + + $this->configurationRepository->method('get')->with('payplug_webhook_authorization_header')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('e4d04233-a15d-4815-af91-698c3eb61c36')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('e4d04233-a15d-4815-af91-698c3eb61c36'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42, 7400, '000000065'), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_whenAlreadyTreated_isIdempotentAndDoesNotReapply(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_123')->willReturn(true); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->lock->expects(self::once())->method('release')->with('payplug_upc_treat_op_123'); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onInvalidSignature_throwsInvalidNotificationExceptionWithoutApplyingTheOutcome(): void + { + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->expectException(InvalidNotificationException::class); + + $this->handler->treat($this->payment(), '{}', ['Authorization' => 'Bearer wrong-secret']); + } + + public function testTreat_onOrderIdMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void + { + // Now that no Authorization header is required (see WebhookNotificationHelper), this + // orderId/amount cross-check is the only remaining protection against a notification + // being applied to the wrong payment on the static, per-account IPN receiver. + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => 'some-other-order', 'amount' => 1000]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->logger->expects(self::once())->method('error'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void + { + $body = \json_encode(['id' => 'op_123', 'execCode' => '0000', 'orderId' => '42', 'amount' => 999]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->logger->expects(self::once())->method('error'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(42, 1000), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A 3DS-challenge capture never gets an alias back synchronously — this webhook, fired once + * the challenge is validated, is the only place a 3DS payment's card ever gets saved. The + * alias/card metadata is already in the webhook body itself: same paymentMethod.{id, card, + * details} shape as the operation resource CaptureHostedPaymentRequestHandler fetches + * separately for a frictionless payment. + */ + public function testTreat_onPaidOutcomeWithSaveCardRequested_persistsANewCard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => [ + 'id' => 'card_new_1', + 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242'], + 'details' => ['selectedBrand' => 'VISA', 'validityDate' => '2030-12'], + ], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + + self::assertSame('card_new_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + } + + public function testTreat_onPaidOutcomeWithSaveCardRequestedAndAliasInStoredId_persistsANewCard(): void + { + // Shape of a real staging webhook: the alias is in paymentMethod.storedId and there is no + // paymentMethod.id. The card is masked and the ids are fixtures. + $body = \json_encode([ + 'operationType' => 'PAYMENT', + 'customer' => ['id' => '130', 'email' => 'test-client@example.com'], + 'authentication' => ['status' => 'Y', 'globalStatus' => 'OK', 'mode' => 'FRICTIONLESS', 'version' => '2'], + 'paymentMethod' => [ + 'storedId' => 'card_new_1', + 'card' => ['network' => 'VISA', 'type' => 'VISA', 'code6x4' => '446421XXXXXX0000'], + 'details' => ['fullName' => 'John Doe', 'validityDate' => '2030-12', 'selectedBrand' => 'VISA'], + ], + 'account' => ['id' => 'PLUGINS_UHF_QA'], + 'currency' => 'EUR', + 'amount' => 1000, + 'execCode' => '0000', + 'message' => 'Successful operation', + 'orderId' => '42', + 'id' => 'op_715ac841', + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, '42', ['hosted_fields_save_card' => true], $method, $customer); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + $this->logger->expects(self::never())->method('error'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + + self::assertSame('card_new_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('0000', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + } + + public function testTreat_onPaidOutcomeWithoutSaveCardRequested_doesNotPersistACard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => false]); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onPaidOutcomeWithSaveCardRequestedButNoAliasInPayload_logsAndDoesNotPersistACard(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + // No paymentMethod.id — e.g. this operation never involved an alias at all. + 'paymentMethod' => ['card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $this->logger->expects(self::once())->method('error') + ->with(self::stringContains('no alias id'), self::anything()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onNonPaidOutcome_doesNotAttemptToPersistACard(): void + { + // execCode "9999" maps to PaymentOutcome::FAILED (not PAID, not the 0001 pending case + // already covered elsewhere) — the card-save branch must not even be attempted. + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '9999', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_new_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true]); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onPaidOutcomeWithSaveCardRequestedAndCardAlreadySaved_doesNotPersistADuplicate(): void + { + $body = \json_encode([ + 'id' => 'op_123', + 'execCode' => '0000', + 'orderId' => '42', + 'amount' => 1000, + 'paymentMethod' => ['id' => 'card_existing_1', 'card' => ['network' => 'VISA', 'code6x4' => '424242XXXXXX4242']], + ]); + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->willReturn(false); + + $method = $this->createMock(PaymentMethodInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->payment(42, 1000, details: ['hosted_fields_save_card' => true], method: $method, customer: $customer); + + $this->payplugCardRepository->method('findOneBy')->with(['externalId' => 'card_existing_1', 'isLive' => false])->willReturn(new Card()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * When the notification's operation id matches one recorded under $details['refunds'] + * (RefundPaymentProcessor stores it there for both full and partial UHF refunds), this is a + * refund confirmation, not the payment's own outcome — ExecCodeMapper's "0000" => PAID mapping + * would otherwise misreport a successful refund as the payment being paid. The amount check + * must use the refund's own recorded amount (500), not the payment's full amount (1000). + */ + public function testTreat_onNotificationMatchingAKnownRefundId_appliesRefundedInsteadOfThePaymentOutcome(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A refund confirmation is matched against its OWN recorded amount (500), not the payment's + * full amount (1000) — the pre-fix behavior (comparing against the payment's full amount) + * would reject every partial-refund confirmation, which is exactly the bug this feature fixes. + */ + public function testTreat_onRefundNotificationAmountMismatch_logsAndSkipsWithoutApplyingTheOutcome(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 400]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->logger->expects(self::once())->method('error'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A full UHF refund records its operation id with internal_id: null (RefundPaymentProcessor:: + * processHostedFields() has no Sylius $refundId to attach) — still resolvable and REFUNDED. + */ + public function testTreat_onFullRefundNotification_appliesRefundedUsingTheFullRefundAmount(): void + { + $body = \json_encode(['id' => 'op_refund_full', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $details = ['refunds' => [['internal_id' => null, 'id' => 'op_refund_full', 'amount' => 1000]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_full')->willReturn(false); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * RefundPaymentProcessor's createRefund() call can return a 2xx response with no operationIds + * (logged as an error there) — the refund entry it records then has id: null, so this + * confirmation's own operationId ("op_refund_unresolved") can never match it by id. Falling + * back to the unresolved entry's own recorded amount (500) is what still lets this be + * classified as REFUNDED instead of being dropped or misapplied as a plain payment + * confirmation. + */ + public function testTreat_onNotificationForARefundWithNoCapturedOperationId_fallsBackToTheUnresolvedRefundEntry(): void + { + $body = \json_encode(['id' => 'op_refund_unresolved', 'execCode' => '0000', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_unresolved')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_unresolved'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::REFUNDED); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * The refund's own execCode indicates failure (anything other than "0000") — the outcome + * must never be forced to REFUNDED (money never moved), nor passed through as-is to the + * Payment's own state machine: PaymentOutcome::FAILED maps to TRANSITION_FAIL (see + * SyliusOrderStateMutator), which means "this PAYMENT failed," not "this refund attempt + * failed" — the underlying payment already succeeded, only the refund didn't. Only logging + + * idempotency tracking happen; orderStateMutator must never be called. The matched refund + * entry is also flagged 'failed' => true on the Payment itself — see + * RefundPaymentProcessor::sumRecordedRefunds(), which relies on this flag to exclude money + * that was accepted synchronously but never actually moved from a later full refund's + * remaining-balance calculation. + */ + public function testTreat_onRefundNotificationWithFailureExecCode_neverTouchesThePaymentStateButStillMarksTreated(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $payment = $this->payment(42, 1000, null, $details); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => 77, + 'id' => 'op_refund_1', + 'amount' => 500, + 'failed' => true, + ]] === $details['refunds']; + }, + )); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_refund_1'); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('error'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * The 'failed' flag write goes through RefundDetailsLockKey — the same lock key + * RefundPaymentProcessor::processHostedFields()/processHostedFieldsWithAmount() acquire + * around their own (network-call-spanning) read-modify-write of this same + * $details['refunds'] array — not the per-operation 'payplug_upc_treat_' lock applyLocked() + * uses afterwards. Both locks are acquired/released here: the refund-details one first + * (guarding the setDetails() write below), the treat one second (guarding + * isTreated()/markTreated()/save()). + */ + public function testTreat_onRefundNotificationWithFailureExecCode_acquiresTheSharedRefundDetailsLockKeyBeforeWritingTheFailedFlag(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_refund_1')->willReturn(false); + + $acquiredKeys = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback(static function (string $key, int $ttl) use (&$acquiredKeys): bool { + $acquiredKeys[] = [$key, $ttl]; + + return true; + }); + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + + self::assertSame( + [['payplug_upc_refund_details_42', 30], ['payplug_upc_treat_op_refund_1', 30]], + $acquiredKeys, + ); + } + + /** + * A refund creation (RefundPaymentProcessor::processHostedFields()/ + * processHostedFieldsWithAmount()) is in progress for this payment right now, holding + * RefundDetailsLockKey. The notification must NOT be marked treated in that case — returning + * without ever calling applyLocked() leaves isTreated()/markTreated() untouched, so a later + * redelivery of the same notification gets a fresh chance to record the 'failed' flag once + * that refund creation has released the lock — instead of the flag being silently lost forever + * because this delivery was marked treated without ever recording it. + */ + public function testTreat_onRefundNotificationWithFailureExecCode_whenRefundDetailsLockCannotBeAcquired_doesNotMarkTreated(): void + { + $body = \json_encode(['id' => 'op_refund_1', 'execCode' => '9999', 'orderId' => '42', 'amount' => 500]); + $details = ['refunds' => [['internal_id' => 77, 'id' => 'op_refund_1', 'amount' => 500]]]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback( + static fn (string $key): bool => 'payplug_upc_refund_details_42' !== $key, + ); + $this->handler = new HostedFieldsWebhookNotificationHandler( + $this->paymentRepository, + $this->orderStateMutator, + $this->configurationRepository, + $this->lock, + $this->logger, + new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry), + ); + + $payment = $this->payment(42, 1000, null, $details); + $payment->expects(self::never())->method('setDetails'); + $this->paymentRepository->expects(self::never())->method('isTreated'); + $this->paymentRepository->expects(self::never())->method('save'); + $this->paymentRepository->expects(self::never())->method('markTreated'); + $this->orderStateMutator->expects(self::never())->method('apply'); + // Once for the "non-success outcome" log, once for the lock-contention log. + $this->logger->expects(self::exactly(2))->method('error'); + + $this->handler->treat($payment, $body, ['Authorization' => 'Bearer shared-secret']); + } + + /** + * A delayed/redelivered copy of the ORIGINAL payment-creation notification must never be + * misclassified as a refund confirmation just because this payment also has an unresolved + * (id: null) refund entry sitting in $details['refunds'] — the known payment-creation + * operation id (hosted_fields_operation_id) excludes it from the unresolved-entry fallback. + */ + public function testTreat_onRedeliveredPaymentNotification_isNotMisclassifiedAsTheUnresolvedRefund(): void + { + $body = \json_encode(['id' => 'op_payment_1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000]); + $details = [ + 'hosted_fields_operation_id' => 'op_payment_1', + 'refunds' => [['internal_id' => 77, 'id' => null, 'amount' => 500]], + ]; + + $this->configurationRepository->method('get')->willReturn('Bearer shared-secret'); + $this->paymentRepository->method('isTreated')->with('op_payment_1')->willReturn(false); + + $this->paymentRepository->expects(self::once())->method('save'); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_payment_1'); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->handler->treat($this->payment(42, 1000, null, $details), $body, ['Authorization' => 'Bearer shared-secret']); + } + + public function testTreat_onADeferredCapturePaymentsOwnConfirmation_appliesAuthorizedNotPaid(): void + { + $body = \json_encode(['id' => 'op_auth', 'execCode' => '0000', 'orderId' => '42', 'amount' => 1000, 'maxCaptureDate' => '2026-10-01T12:00:00+00:00']); + $payment = $this->payment(42, 1000, null, [ + 'hosted_fields_operation_id' => 'op_auth', + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::AUTHORIZED); + // A 3DS authorization only learns its capture deadline here. + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => '2026-10-01T12:00:00+00:00' === $details[AuthorizationDetails::MAX_CAPTURE_DATE], + )); + + $this->handler->treat($payment, (string) $body, []); + } + + public function testTreat_onACaptureConfirmation_onlyTracksItWithoutTouchingThePaymentState(): void + { + $body = \json_encode(['id' => 'op_c1', 'execCode' => '0000', 'orderId' => '42', 'amount' => 300]); + $payment = $this->payment(42, 1000, null, [ + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 300]], + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->paymentRepository->expects(self::once())->method('markTreated')->with('op_c1'); + // Its "0000" must never reach the payment as PAID: the capture's effect was already + // applied synchronously, and a partial capture leaves the payment authorized. + $this->orderStateMutator->expects(self::never())->method('apply'); + $payment->expects(self::never())->method('setDetails'); + + $this->handler->treat($payment, (string) $body, []); + } + + public function testTreat_onAFailedCaptureConfirmation_flagsTheCaptureFailedAndAlertsTheMerchant(): void + { + $body = \json_encode(['id' => 'op_c1', 'execCode' => '4001', 'orderId' => '42', 'amount' => 300]); + $payment = $this->payment(42, 1000, null, [ + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 1000, + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 300]], + ]); + + $this->paymentRepository->method('isTreated')->willReturn(false); + $this->orderStateMutator->expects(self::never())->method('apply'); + $this->logger->expects(self::once())->method('critical'); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => true === $details[AuthorizationDetails::CAPTURES][0]['failed'], + )); + + $this->handler->treat($payment, (string) $body, []); + } +} diff --git a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php index 62d92df0..a1bd28e6 100644 --- a/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php +++ b/tests/PHPUnit/Handler/PaymentNotificationHandlerTest.php @@ -4,7 +4,9 @@ namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\Handler; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Payplug\Resource\Payment as PayplugPayment; use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Entity\Card; @@ -39,6 +41,8 @@ final class PaymentNotificationHandlerTest extends TestCase private RequestStack&MockObject $requestStack; + private ManagerRegistry&MockObject $managerRegistry; + private PaymentNotificationHandler $handler; protected function setUp(): void @@ -50,6 +54,7 @@ protected function setUp(): void $this->entityManager = $this->createMock(EntityManagerInterface::class); $this->lockFactory = $this->createMock(LockFactory::class); $this->requestStack = $this->createMock(RequestStack::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); $this->handler = new PaymentNotificationHandler( $this->logger, @@ -59,6 +64,7 @@ protected function setUp(): void $this->entityManager, $this->lockFactory, $this->requestStack, + $this->managerRegistry, ); } @@ -328,6 +334,61 @@ public function testTreat_withIsPaidAndMissingCustomerId_doesNotSaveCard(): void self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); } + // ------------------------------------------------------------------------- + // treat() — card saving: concurrent save for the same alias does not throw + // ------------------------------------------------------------------------- + + /** + * Two payments notified concurrently for the same card alias can both pass the findOneBy() + * guard before either commits; the DB-level unique constraint then rejects the second add(). + * Verifies that race is swallowed rather than propagated as an uncaught exception. + */ + public function testTreat_whenAddLosesARaceAgainstAConcurrentSaveForTheSameAlias_doesNotThrow(): void + { + $lock = $this->buildLock(); + $this->lockFactory->method('createLock')->willReturn($lock); + + $customer = $this->createMock(CustomerInterface::class); + $this->customerRepository->method('find')->with(9)->willReturn($customer); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($this->createMock(PaymentMethodInterface::class)); + $this->entityManager->method('refresh'); + + $paymentResource = $this->buildPayment([ + 'id' => 'pay_008', + 'is_paid' => true, + 'is_live' => false, + 'created_at' => time(), + 'metadata' => ['customer_id' => 9], + 'card' => ['id' => 'card_external_race', 'brand' => 'Visa', 'country' => 'FR', 'last4' => '4242', 'exp_month' => 12, 'exp_year' => 2030], + ]); + + $this->payplugCardRepository->method('findOneBy')->willReturn(null); + + $card = $this->createMock(Card::class); + $card->method('setCustomer')->willReturnSelf(); + $card->method('setPaymentMethod')->willReturnSelf(); + $card->method('setExternalId')->willReturnSelf(); + $card->method('setBrand')->willReturnSelf(); + $card->method('setCountryCode')->willReturnSelf(); + $card->method('setLast4')->willReturnSelf(); + $card->method('setExpirationMonth')->willReturnSelf(); + $card->method('setExpirationYear')->willReturnSelf(); + $card->method('setIsLive')->willReturnSelf(); + + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->method('add')->with($card) + ->willThrowException($this->createMock(UniqueConstraintViolationException::class)); + $this->managerRegistry->expects(self::once())->method('resetManager'); + + $details = new \ArrayObject(['status' => PayPlugApiClientInterface::STATUS_CREATED]); + + $this->handler->treat($payment, $paymentResource, $details); + + self::assertSame(PayPlugApiClientInterface::STATUS_CAPTURED, $details['status']); + } + // ------------------------------------------------------------------------- // treat() — card NOT saved when card already exists in repo // ------------------------------------------------------------------------- diff --git a/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php new file mode 100644 index 00000000..d274d115 --- /dev/null +++ b/tests/PHPUnit/OrderPay/Provider/CaptureHttpResponseProviderTest.php @@ -0,0 +1,108 @@ +provider = new CaptureHttpResponseProvider(); + $this->requestConfiguration = $this->createMock(RequestConfiguration::class); + } + + private function paymentRequest(string $action, array $responseData): PaymentRequestInterface&MockObject + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getAction')->willReturn($action); + $paymentRequest->method('getResponseData')->willReturn($responseData); + + return $paymentRequest; + } + + public function testSupports_whenRedirectUrlIsSetOnCapture_returnsTrue(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']); + + self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenRedirectHtmlIsSetOnCapture_returnsTrue(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => '']); + + self::assertTrue($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenActionIsNotCapture_returnsFalse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_NOTIFY, ['redirect_url' => 'https://example.com/3ds']); + + self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testSupports_whenNeitherRedirectFieldIsSet_returnsFalse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['status' => 'processing']); + + self::assertFalse($this->provider->supports($this->requestConfiguration, $paymentRequest)); + } + + public function testGetResponse_whenRedirectUrlIsSet_returnsARedirectResponse(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_url' => 'https://example.com/3ds']); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('https://example.com/3ds', $response->getTargetUrl()); + } + + public function testGetResponse_whenRedirectHtmlIsSet_returnsThatHtmlAsTheResponseContent(): void + { + $html = '3DS challenge form'; + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, ['redirect_html' => $html]); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertSame($html, $response->getContent()); + } + + /** + * Not a real-world case (the handler only ever sets one or the other), but proves the + * precedence explicitly rather than leaving it implicit: redirect_html wins if both are set. + */ + public function testGetResponse_whenBothRedirectFieldsAreSet_prefersRedirectHtml(): void + { + $html = '3DS challenge form'; + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, [ + 'redirect_url' => 'https://example.com/3ds', + 'redirect_html' => $html, + ]); + + $response = $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + + self::assertSame($html, $response->getContent()); + } + + public function testGetResponse_whenNeitherRedirectFieldIsSet_throws(): void + { + $paymentRequest = $this->paymentRequest(PaymentRequestInterface::ACTION_CAPTURE, []); + + $this->expectException(\LogicException::class); + + $this->provider->getResponse($this->requestConfiguration, $paymentRequest); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php b/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php new file mode 100644 index 00000000..c99683b9 --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/AuthorizedPaymentOperationProcessorTest.php @@ -0,0 +1,534 @@ +operator = $this->createMock(AuthorizationOperatorInterface::class); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->stateMachine->method('can')->willReturn(true); + $this->clock = new MockClock('2026-09-24T10:00:00+00:00'); + + $this->processor = new AuthorizedPaymentOperationProcessor( + $this->operator, + $this->lock, + $this->entityManager, + $this->stateMachine, + $this->clock, + $this->createMock(LoggerInterface::class), + ); + } + + public function testCapture_partial_recordsTheOperationAndKeepsThePaymentAuthorized(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('capture') + ->with(self::isInstanceOf(PaymentMethod::class), 'pay_1', '42', 300) + ->willReturn($this->captureOutput('op_c1')); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->capture($payment, 300, 0); + + $authorization = AuthorizationDetails::fromDetails($payment->getDetails()); + self::assertSame(300, $authorization->capturedAmount()); + self::assertSame(700, $authorization->remainingAmount()); + self::assertSame(['operation' => 'capture', 'amount' => 300], $authorization->findOperation('op_c1')); + } + + public function testCapture_ofTheRemainder_sendsTheExactRemainderAndCompletesThePayment(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + + // Never "no amount": the Unified API reads that as the ORIGINAL authorized amount and + // refuses it as a duplicate once part of it has been captured. + $this->operator->expects(self::once())->method('capture') + // Second operation on this authorization: numbered so Payplug doesn't see a duplicate. + ->with(self::anything(), 'pay_1', '42', 700, 'EUR', 2) + ->willReturn($this->captureOutput('op_c2')); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE); + + $this->processor->capture($payment, null, 1); + + self::assertSame(0, AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()); + } + + public function testCancel_ofTheRestAfterAPartialCancellation_sendsTheExactRemainder(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 400)); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', 600) + ->willReturn(new CancellationOutput(200, '{"execCode":"0000","operationIds":["op_v2"]}', null, null)); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); + + $this->processor->cancel($payment, null, 1); + } + + public function testCapture_takesTheDeadlineUpcReturns(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('capture')->willReturn( + new CaptureOutput(200, '{"operationIds":["op_c1"]}', 300, 1000, '2026-09-30T00:00:00+00:00'), + ); + + $this->processor->capture($payment, 300); + + self::assertSame('2026-09-30T00:00:00+00:00', $payment->getDetails()[AuthorizationDetails::MAX_CAPTURE_DATE]); + } + + public function testCapture_aboveTheRemainder_isRefusedWithoutCallingUpc(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, 1001)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.amount_exceeds_remaining', $exception->getTranslationKey()); + self::assertSame(['%remaining%' => '10.00 EUR'], $exception->getTranslationParameters()); + } + + public function testCapture_aboveTheRemainderInUsd_formatsTheRemainingAmountInUsd(): void + { + $payment = $this->authorizedPayment(1000, currency: 'USD'); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, 1001)); + + self::assertSame(['%remaining%' => '10.00 USD'], $exception->getTranslationParameters()); + } + + /** + * @return iterable + */ + public static function typedAmountCurrencies(): iterable + { + yield 'EUR' => ['EUR', true]; + yield 'USD' => ['USD', true]; + yield 'lowercase eur' => ['eur', true]; + yield 'zero-decimal JPY' => ['JPY', false]; + yield 'zero-decimal XPF' => ['XPF', false]; + yield 'three-decimal KWD' => ['KWD', false]; + yield 'empty' => ['', false]; + yield 'malformed' => ['EU', false]; + yield 'with a digit' => ['E1R', false]; + yield 'no currency' => [null, false]; + } + + /** + * @dataProvider typedAmountCurrencies + */ + public function testSupportsTypedAmounts_onlyForATwoDecimalCurrency(?string $currency, bool $expected): void + { + self::assertSame($expected, AuthorizedPaymentOperationProcessor::supportsTypedAmounts($currency)); + } + + /** + * @return iterable + */ + public static function explicitAmountsInUnsupportedCurrencies(): iterable + { + yield 'capture in JPY' => ['capture', 'JPY']; + yield 'cancel in KWD' => ['cancel', 'KWD']; + yield 'capture without a currency' => ['capture', '']; + } + + /** + * @dataProvider explicitAmountsInUnsupportedCurrencies + */ + public function testAnExplicitAmountInAnUnsupportedCurrency_isRefusedBeforeUpcAndRecordsNothing( + string $operation, + string $currency, + ): void + { + $payment = $this->authorizedPayment(1000, currency: $currency); + $detailsBefore = $payment->getDetails(); + $this->operator->expects(self::never())->method('capture'); + $this->operator->expects(self::never())->method('cancel'); + $this->entityManager->expects(self::never())->method('flush'); + + $exception = $this->catchRefusal(fn () => 'capture' === $operation + ? $this->processor->capture($payment, 500) + : $this->processor->cancel($payment, 500)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.unsupported_currency', $exception->getTranslationKey()); + self::assertSame(['%currency%' => $currency], $exception->getTranslationParameters()); + self::assertSame($detailsBefore, $payment->getDetails()); + } + + public function testCapture_ofTheWholeRemainderInAZeroDecimalCurrency_sendsTheStoredAmount(): void + { + $payment = $this->authorizedPayment(1000, currency: 'JPY'); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', '42', 1000, 'JPY') + ->willReturn($this->captureOutput('op_c1')); + + $this->processor->capture($payment, null); + + self::assertSame(1000, AuthorizationDetails::fromDetails($payment->getDetails())->capturedAmount()); + } + + public function testCapture_withAStaleVersion_isRefusedSoAReplayedFormCannotCaptureTwice(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, 300, 0)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.stale', $exception->getTranslationKey()); + } + + public function testCapture_flushesTheRecordBeforeReleasingTheLock(): void + { + $calls = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->lock->method('release')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'release'; + }); + $this->entityManager->method('flush')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'flush'; + }); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $this->lock, $this->entityManager, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->method('capture')->willReturn($this->captureOutput('op_c1')); + + $processor->capture($this->authorizedPayment(1000), 300, 0); + + // A second request taking the lock the moment it is released must already see the + // recorded operation, or its version check passes and UPC is asked to capture twice. + self::assertSame(['flush', 'release'], $calls); + } + + public function testCancel_flushesTheRecordBeforeReleasingTheLock(): void + { + $calls = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->lock->method('release')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'release'; + }); + $this->entityManager->method('flush')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'flush'; + }); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $this->lock, $this->entityManager, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->method('cancel')->willReturn(new CancellationOutput(200, '{"operationIds":["op_x1"]}', null, null, null)); + + $processor->cancel($this->authorizedPayment(1000), 400, 0); + + self::assertSame(['flush', 'release'], $calls); + } + + public function testOnCompleteTransition_flushesTheCaptureBeforeReleasingTheLock(): void + { + $calls = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->lock->method('release')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'release'; + }); + $this->entityManager->method('flush')->willReturnCallback(static function () use (&$calls): void { + $calls[] = 'flush'; + }); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $this->lock, $this->entityManager, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->method('capture')->willReturn($this->captureOutput('op_c1')); + + $processor->onCompleteTransition($this->transitionEvent($this->authorizedPayment(1000))); + + self::assertSame(['flush', 'release'], $calls); + } + + public function testCapture_whenTheFlushFailsAfterUpcAccepted_releasesTheLockAndRethrows(): void + { + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); + $this->lock->expects(self::once())->method('release'); + $this->entityManager->method('flush')->willThrowException(new \RuntimeException('connection lost')); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $this->lock, $this->entityManager, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->expects(self::once())->method('capture')->willReturn($this->captureOutput('op_c1')); + + $this->expectException(\RuntimeException::class); + + $processor->capture($this->authorizedPayment(1000), 300, 0); + } + + public function testCapture_refusedByUpc_flushesNothing(): void + { + $this->operator->method('capture')->willThrowException(new PaymentNotCapturableException('nope')); + $this->entityManager->expects(self::never())->method('flush'); + + $this->catchRefusal(fn () => $this->processor->capture($this->authorizedPayment(1000), 300, 0)); + } + + public function testCapture_whileAnotherOperationHoldsTheLock_isRefused(): void + { + $lock = $this->createMock(ILock::class); + $lock->method('acquire')->with('payplug_upc_authorization_42', 30)->willReturn(false); + $processor = new AuthorizedPaymentOperationProcessor($this->operator, $lock, $this->entityManager, $this->stateMachine, $this->clock, $this->createMock(LoggerInterface::class)); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $processor->capture($this->authorizedPayment(1000), 300)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.in_progress', $exception->getTranslationKey()); + } + + public function testCapture_afterTheDeadline_isRefusedWithoutCallingUpc(): void + { + $payment = $this->authorizedPayment(1000, '2026-09-24T09:00:00+00:00'); + $this->operator->expects(self::never())->method('capture'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.expired', $exception->getTranslationKey()); + self::assertFalse($this->processor->canCapture($payment)); + } + + public function testCapture_onAPaymentNotAuthorized_isRefused(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setState(PaymentInterface::STATE_COMPLETED); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.not_authorized', $exception->getTranslationKey()); + } + + /** + * @return iterable + */ + public static function upcRefusals(): iterable + { + yield 'expired' => [new AuthorizationExpiredException('Authorization expired'), 'expired']; + yield 'not capturable' => [new PaymentNotCapturableException('Payment is not capturable'), 'not_operable']; + // Staging, 2026-09-24: every capture after the first on this account → HTTP 200 + 4011. + yield 'second capture' => [new MultipleCaptureNotAllowedException('Duplicate request.'), 'multiple_capture_not_allowed']; + yield 'generic' => [new ApiException('HTTP 500'), 'api_error']; + } + + /** + * @dataProvider upcRefusals + */ + public function testCapture_refusedByUpc_leavesDetailsAndStateUntouched( + \Throwable $upcException, + string $reason, + ): void + { + $payment = $this->authorizedPayment(1000); + $detailsBefore = $payment->getDetails(); + $this->operator->method('capture')->willThrowException($upcException); + $this->stateMachine->expects(self::never())->method('apply'); + $this->lock->expects(self::once())->method('release')->with('payplug_upc_authorization_42'); + + $exception = $this->catchRefusal(fn () => $this->processor->capture($payment, null)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.' . $reason, $exception->getTranslationKey()); + self::assertSame($detailsBefore, $payment->getDetails()); + self::assertSame(PaymentInterface::STATE_AUTHORIZED, $payment->getState()); + } + + public function testCancel_full_sendsNoAmountAndCancelsThePayment(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', null) + ->willReturn(new CancellationOutput(200, '{"operationIds":["op_v1"]}', 1000, 1000)); + $this->stateMachine->expects(self::once())->method('apply') + ->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_CANCEL); + + $this->processor->cancel($payment, null, 0); + + self::assertSame(1000, AuthorizationDetails::fromDetails($payment->getDetails())->cancelledAmount()); + } + + public function testCancel_partial_keepsTheRestCapturable(): void + { + $payment = $this->authorizedPayment(1000); + + $this->operator->expects(self::once())->method('cancel') + ->with(self::anything(), 'pay_1', '42', 400) + ->willReturn(new CancellationOutput(200, '{"operationIds":["op_v1"]}', 400, 1000)); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->cancel($payment, 400); + + self::assertSame(600, AuthorizationDetails::fromDetails($payment->getDetails())->remainingAmount()); + self::assertTrue($this->processor->canCapture($payment)); + } + + public function testCancel_partialNotAllowedByTheContract_isExplained(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('cancel')->willThrowException(new PartialCancellationNotAllowedException('not enabled')); + + $exception = $this->catchRefusal(fn () => $this->processor->cancel($payment, 400)); + + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.partial_cancellation_not_allowed', $exception->getTranslationKey()); + } + + public function testCancel_afterACapture_isNeitherOfferedNorSent(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300)); + $this->operator->expects(self::never())->method('cancel'); + + self::assertFalse($this->processor->canCancel($payment)); + self::assertTrue($this->processor->canCapture($payment)); + + $exception = $this->catchRefusal(fn () => $this->processor->cancel($payment, null)); + self::assertSame('payplug_sylius_payplug_plugin.admin.authorization.error.cancel_after_capture', $exception->getTranslationKey()); + } + + public function testOnCompleteTransition_capturesWhatIsStillAuthorized(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->expects(self::once())->method('capture') + ->with(self::anything(), 'pay_1', '42', 1000, 'EUR') + ->willReturn($this->captureOutput('op_c1')); + // Already inside the "complete" transition: must not apply it a second time. + $this->stateMachine->expects(self::never())->method('apply'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + + self::assertSame(1000, AuthorizationDetails::fromDetails($payment->getDetails())->capturedAmount()); + } + + public function testOnCompleteTransition_isANoOpOnceNothingIsLeftToCapture(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(AuthorizationDetails::withOperation($payment->getDetails(), AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 1000)); + $this->operator->expects(self::never())->method('capture'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + public function testOnCompleteTransition_ignoresAnImmediateCapturePayment(): void + { + $payment = $this->authorizedPayment(1000); + $payment->setDetails(['hosted_fields_payment_id' => 'pay_1']); + $this->operator->expects(self::never())->method('capture'); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + public function testOnCompleteTransition_abortsTheTransitionWhenTheCaptureIsRefused(): void + { + $payment = $this->authorizedPayment(1000); + $this->operator->method('capture')->willThrowException(new ApiException('HTTP 500')); + + $this->expectException(AuthorizationOperationException::class); + + $this->processor->onCompleteTransition($this->transitionEvent($payment)); + } + + private function authorizedPayment( + int $amount, + ?string $maxCaptureDate = '2026-09-30T10:00:00+00:00', + string $currency = 'EUR', + ): Payment + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_1', + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + ]); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $payment = new class() extends Payment { + public function getId(): int + { + return 42; + } + }; + // Sylius's core Payment asserts it has an order; one without a number yet makes the + // orderId sent to UPC fall back to the payment id ("42"). + $payment->setOrder(new Order()); + $payment->setMethod($method); + $payment->setAmount($amount); + $payment->setCurrencyCode($currency); + $payment->setState(PaymentInterface::STATE_AUTHORIZED); + $payment->setDetails(AuthorizationDetails::open( + ['hosted_fields_payment_id' => 'pay_1'], + new PaymentOutput(201, '{}', null, null, null, $maxCaptureDate, $amount), + $amount, + )); + + return $payment; + } + + private function captureOutput(string $operationId): CaptureOutput + { + return new CaptureOutput(200, (string) \json_encode(['operationIds' => [$operationId]]), null, null, null); + } + + private function transitionEvent(PaymentInterface $payment): TransitionEvent + { + return new TransitionEvent($payment, new Marking([PaymentInterface::STATE_AUTHORIZED => 1])); + } + + private function catchRefusal(\Closure $operation): AuthorizationOperationException + { + try { + $operation(); + } catch (AuthorizationOperationException $exception) { + return $exception; + } + + self::fail('Expected an AuthorizationOperationException.'); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php new file mode 100644 index 00000000..a4a24faa --- /dev/null +++ b/tests/PHPUnit/PaymentProcessing/NullHostedFieldsPaymentProcessorTest.php @@ -0,0 +1,46 @@ +logger = $this->createMock(LoggerInterface::class); + $this->processor = new NullHostedFieldsPaymentProcessor($this->logger); + } + + public function testProcess_storesCardMetadataAlongsideTokenAndBrand(): void + { + $this->logger->expects(self::once())->method('info'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getDetails')->willReturn(['existing_key' => 'kept']); + $payment->expects(self::once())->method('setDetails')->with([ + 'existing_key' => 'kept', + 'hosted_fields_token' => 'hf_token_abc', + 'hosted_fields_selected_brand' => 'VISA', + 'hosted_fields_save_card' => true, + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => 2030, + 'hosted_fields_country' => 'FR', + 'status' => PaymentInterface::STATE_PROCESSING, + ]); + + $this->processor->process($payment, new HostedFieldsCaptureData('hf_token_abc', 'VISA', true, '4242', 12, 2030, 'FR')); + } +} diff --git a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php index 3a2d3763..943db412 100644 --- a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php @@ -14,16 +14,24 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +use PayPlug\SyliusPayPlugPlugin\Upc\AuthorizationDetails; +use PayPlug\SyliusPayPlugPlugin\Upc\RefundCreatorInterface; +use PayplugUnifiedCore\Contracts\ILock; +use PayplugUnifiedCore\Exceptions\ApiException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Resource\Exception\UpdateHandlingException; use Sylius\Component\Resource\Repository\RepositoryInterface; use Sylius\RefundPlugin\Entity\RefundPayment; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Contracts\Translation\TranslatorInterface; final class RefundPaymentProcessorTest extends TestCase @@ -42,6 +50,10 @@ final class RefundPaymentProcessorTest extends TestCase private PayPlugApiClientInterface&MockObject $apiClient; + private RefundCreatorInterface&MockObject $refundCreator; + + private ILock&MockObject $lock; + private RefundPaymentProcessor $processor; protected function setUp(): void @@ -53,6 +65,9 @@ protected function setUp(): void $this->payplugRefundHistoryRepository = $this->createMock(RefundHistoryRepositoryInterface::class); $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); + $this->refundCreator = $this->createMock(RefundCreatorInterface::class); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(true); $this->apiClientFactory->method('createForPaymentMethod')->willReturn($this->apiClient); @@ -63,6 +78,8 @@ protected function setUp(): void $this->refundPaymentRepository, $this->payplugRefundHistoryRepository, $this->apiClientFactory, + $this->refundCreator, + $this->lock, ); } @@ -238,10 +255,640 @@ public function testProcessWithAmount_apiThrowsException_throwsUpdateHandlingExc $this->processor->processWithAmount($payment, 300, 42); } + // ------------------------------------------------------------------------- + // process() — Hosted Fields (UHF) full refund → calls RefundCreatorInterface + // ------------------------------------------------------------------------- + + /** + * Calls process() with a Hosted-Fields-configured payment. Verifies the UHF refund creator + * is called with the payment's hosted_fields_payment_id and no amount (full refund), and the + * legacy PayPlugApiClient is never touched. + */ + public function testProcess_hostedFields_callsRefundCreatorWithoutAmount(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + + $this->refundCreator->expects(self::once()) + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => '{}']); + $this->apiClient->expects(self::never())->method('refundPayment'); + + $this->processor->process($payment); + } + + /** + * A full refund now records the refund's own operation id under $details['refunds'] (with a + * null internal_id, since there's no Sylius RefundPayment/$refundId in this flow) — otherwise + * HostedFieldsWebhookNotificationHandler could never resolve the payment for the async webhook + * confirming this refund, since PaymentRepository::findOneByPayPlugPaymentId() matches on + * ids present somewhere in Payment::details. + */ + public function testProcess_hostedFields_recordsTheRefundOperationIdInDetails(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * A full refund (process()) triggered after an earlier partial refund must record the + * REMAINING amount actually refunded by omitting $amount to createRefund() — not + * $payment->getAmount() (the original total, 2400 here) — per + * UnifiedApiPaymentService::createRefund()'s own documented "omitting $amount refunds the + * full remaining amount" behavior. Recording the original total instead would make + * matchesPayment() reject this refund's own webhook confirmation (500 already refunded, 1900 + * really remaining) forever. + */ + public function testProcess_hostedFields_afterAPriorPartialRefund_recordsTheRemainingAmountNotTheOriginalTotal(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 1900, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * A deferred-capture payment whose authorization was partly cancelled before being captured + * only ever received the captured amount (1700 of 2400 here): that, not the payment's own + * amount, is what a full refund actually returns. + */ + public function testProcess_hostedFields_onAPartlyCancelledDeferredPayment_recordsTheCapturedAmount(): void + { + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + AuthorizationDetails::DEFERRED => true, + AuthorizationDetails::AUTHORIZED_AMOUNT => 2400, + AuthorizationDetails::CANCELLATIONS => [['id' => 'op_v1', 'amount' => 700]], + AuthorizationDetails::CAPTURES => [['id' => 'op_c1', 'amount' => 1700]], + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static fn (array $details): bool => [['internal_id' => null, 'id' => 'op_ref_full', 'amount' => 1700]] === $details['refunds'], + )); + + $this->refundCreator->method('createRefund') + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * An earlier refund attempt flagged 'failed' => true by + * HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() (its createRefund() call + * was accepted synchronously, but the async confirmation later reported it never actually + * completed) must not count against the remaining balance — a full refund triggered after it + * still records the ORIGINAL total (2400), not 2400 minus the failed attempt's amount. + */ + public function testProcess_hostedFields_afterAFailedPriorRefund_ignoresItInTheRemainingAmountCalculation(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * processHostedFields() must compute its remaining-balance sum from Payment::details read + * AFTER acquiring RefundDetailsLockKey, not from a snapshot taken before it — otherwise a + * concurrent HostedFieldsWebhookNotificationHandler::markMatchedRefundAsFailed() call (run + * while this refund creation's own network call to createRefund() is in flight, both holding + * the same lock key at different times) would have its 'failed' flag silently dropped when + * this method's own stale pre-lock $details gets written back. Simulated here via + * willReturnOnConsecutiveCalls: the first two getDetails() calls (prepare()'s own read, then + * the pre-lock Assert::string(hosted_fields_payment_id) check) see the refund as NOT failed + * yet; the third (taken once the lock is held, per processHostedFields()'s own re-read) sees + * it flagged failed — exactly as if the webhook's write landed in between. Only that third + * snapshot may ever reach setDetails(). + */ + public function testProcess_hostedFields_reReadsDetailsAfterAcquiringTheLock_soAConcurrentlyFlaggedFailedRefundIsNotLost(): void + { + $beforeLock = [ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]], + ]; + $afterLock = [ + 'hosted_fields_payment_id' => 'pay_hf_123', + 'refunds' => [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500, 'failed' => true]], + ]; + + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('000000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturnOnConsecutiveCalls($beforeLock, $beforeLock, $afterLock); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(2400); + $payment->method('getId')->willReturn(42); + + // The failed entry (500) must NOT be subtracted from the original total (2400): had the + // pre-lock snapshot been used instead, this would incorrectly come out to 1900. + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($afterLock): bool { + return [...$afterLock['refunds'], [ + 'internal_id' => null, + 'id' => 'op_ref_full', + 'amount' => 2400, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_123', '000000042', null) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_full']])]); + + $this->processor->process($payment); + } + + /** + * The lock key for a full refund and a partial refund on the SAME payment must be identical — + * otherwise the two can run concurrently and both succeed, double-refunding money, exactly + * the scenario the lock exists to prevent. + */ + public function testProcess_andProcessWithAmount_useTheSameLockKeyForTheSamePayment(): void + { + $acquiredKeys = []; + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturnCallback(static function (string $key) use (&$acquiredKeys): bool { + $acquiredKeys[] = $key; + + return true; + }); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->processor->process($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123'])); + $this->processor->processWithAmount($this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']), 500, 77); + + self::assertCount(2, $acquiredKeys); + self::assertSame($acquiredKeys[0], $acquiredKeys[1]); + } + + /** + * A full refund has no RefundHistory/refundId to check-then-act on (mirrors the legacy + * process()'s own lack of one), so the ILock guard is its only protection against a + * concurrent second call for the same payment double-refunding. + */ + public function testProcess_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_123']); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(false); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->logger->expects(self::once())->method('error'); + + $this->processor->process($payment); + } + + /** + * The UHF refund creator throws an ApiException (a UPC exception, always a subtype of the + * base \Exception). Verifies the processor catches it the same way as the legacy client's + * exceptions, logs an error, and re-throws UpdateHandlingException. + */ + public function testProcess_hostedFields_apiExceptionThrowsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_fail']); + + $this->refundCreator->method('createRefund')->willThrowException(new ApiException('API error')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->process($payment); + } + + // ------------------------------------------------------------------------- + // processWithAmount() — Hosted Fields (UHF) partial refund → RefundCreatorInterface + + // RefundHistory bookkeeping + // ------------------------------------------------------------------------- + + /** + * Calls processWithAmount() on a Hosted-Fields payment. Verifies the UHF refund creator is + * called with the amount, setDetails() records the refund's own operation id (from the + * response's operationIds[0]) under $details['refunds'], and a RefundHistory entry is + * persisted — externalId stays null, mirroring the legacy flow's own convention that this + * field is reserved for the async webhook-confirmed refund, not the synchronous BO one. + */ + public function testProcessWithAmount_hostedFields_createsRefundHistoryEntryFromOperationIds(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details): bool { + return [[ + 'internal_id' => 77, + 'id' => 'op_ref_1', + 'amount' => 500, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 500) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_1']])]); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment); + + $this->payplugRefundHistoryRepository->expects(self::once())->method('add')->with(self::callback( + static function (RefundHistory $refundHistory): bool { + return null === $refundHistory->getExternalId() && + 500 === $refundHistory->getValue() && + $refundHistory->isProcessed(); + }, + )); + + $this->processor->processWithAmount($payment, 500, 77); + } + + public function testProcessWithAmount_hostedFields_apiExceptionThrowsUpdateHandlingException(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial_fail']); + + $this->refundCreator->method('createRefund')->willThrowException(new ApiException('fail')); + $this->logger->expects(self::once())->method('error'); + + $this->processor->processWithAmount($payment, 300, 42); + } + + /** + * The whole check-then-act sequence (RefundHistory lookup + createRefund() call) is also + * guarded by ILock, keyed by $refundId: without it, two concurrent calls for the same + * $refundId could both pass the RefundHistory check below before either persists one, and + * both would go on to call createRefund() — a lock is what actually serializes the two + * attempts, a plain check-then-act on its own cannot. + */ + public function testProcessWithAmount_hostedFields_whenLockCannotBeAcquired_throwsUpdateHandlingExceptionWithoutCallingRefundCreator(): void + { + $this->expectException(UpdateHandlingException::class); + + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $this->lock = $this->createMock(ILock::class); + $this->lock->method('acquire')->willReturn(false); + $this->processor = new RefundPaymentProcessor( + $this->requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $this->lock, + ); + + $this->refundPaymentRepository->expects(self::never())->method('findOneBy'); + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->logger->expects(self::once())->method('error'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * Unlike the legacy flow (which forwards Sylius's own $refundId to the API as a de-facto + * idempotency key), UPC's createRefund() has no idempotency-key parameter at all. A retried + * delivery of the same RefundPaymentGenerated message (e.g. after a transient failure once + * the RefundHistory for this $refundId was already persisted) must not call createRefund() + * again — this is the local guard closing that window. + */ + public function testProcessWithAmount_hostedFields_alreadyProcessed_skipsDuplicateRefundCall(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + $payment->expects(self::never())->method('setDetails'); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 77])->willReturn($refundPayment); + + $existingRefundHistory = $this->createMock(RefundHistory::class); + $this->payplugRefundHistoryRepository + ->method('findOneBy') + ->with(['refundPayment' => $refundPayment]) + ->willReturn($existingRefundHistory); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->payplugRefundHistoryRepository->expects(self::never())->method('add'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * createRefund() returns a 2xx response whose body has no operationIds (malformed/unexpected + * shape). The refund still succeeded (money moved) and is still recorded, but with no + * tracking id — this must not pass silently, so an error is logged (actionable: without an + * operation id, HostedFieldsWebhookNotificationHandler can never match the eventual webhook + * confirmation back to this refund). + */ + public function testProcessWithAmount_hostedFields_onMissingOperationIds_logsAnError(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->logger->expects(self::once())->method('error'); + $this->payplugRefundHistoryRepository->expects(self::once())->method('add'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * Two sequential partial refunds against the same Hosted-Fields payment must accumulate in + * $details['refunds'] rather than the second call overwriting the first — mirrors the + * legacy gateway's own Behat coverage for this exact scenario ("Two Partial refund of one + * product"), which UHF otherwise has no equivalent for at any test level. + */ + public function testProcessWithAmount_hostedFields_secondPartialRefund_appendsToExistingRefunds(): void + { + $existingRefunds = [['internal_id' => 77, 'id' => 'op_ref_1', 'amount' => 500]]; + $payment = $this->buildHostedFieldsPayment([ + 'hosted_fields_payment_id' => 'pay_hf_partial', + 'refunds' => $existingRefunds, + ]); + $payment->expects(self::once())->method('setDetails')->with(self::callback( + static function (array $details) use ($existingRefunds): bool { + return [...$existingRefunds, [ + 'internal_id' => 78, + 'id' => 'op_ref_2', + 'amount' => 300, + ]] === $details['refunds']; + }, + )); + + $this->refundCreator + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_partial', '000000042', 300) + ->willReturn(['status' => 200, 'body' => json_encode(['operationIds' => ['op_ref_2']])]); + + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->with(['id' => 78])->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->processor->processWithAmount($payment, 300, 78); + } + + /** + * prepare() must skip the legacy PayPlugApiClientFactory entirely for a Hosted-Fields + * payment — building it would mint an OAuth2 token that's never used. + */ + public function testProcessWithAmount_hostedFields_neverCreatesTheLegacyApiClient(): void + { + $payment = $this->buildHostedFieldsPayment(['hosted_fields_payment_id' => 'pay_hf_partial']); + + $this->refundCreator->method('createRefund')->willReturn(['status' => 200, 'body' => '{}']); + $refundPayment = $this->createMock(RefundPayment::class); + $this->refundPaymentRepository->method('findOneBy')->willReturn($refundPayment); + $this->payplugRefundHistoryRepository->method('findOneBy')->willReturn(null); + + $this->apiClientFactory->expects(self::never())->method('createForPaymentMethod'); + + $this->processor->processWithAmount($payment, 500, 77); + } + + /** + * When the payment has no order (edge case), orderId falls back to the payment's own id — + * same convention CaptureHostedPaymentRequestHandler already uses at creation time. + */ + public function testProcess_hostedFields_withNoOrder_fallsBackToThePaymentIdAsOrderId(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn(['hosted_fields_payment_id' => 'pay_hf_no_order']); + $payment->method('getOrder')->willReturn(null); + $payment->method('getId')->willReturn(99); + $payment->method('getAmount')->willReturn(2400); + + $this->refundCreator->expects(self::once()) + ->method('createRefund') + ->with(self::isInstanceOf(PaymentMethodInterface::class), 'pay_hf_no_order', '99', null) + ->willReturn(['status' => 200, 'body' => '{}']); + + $this->processor->process($payment); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // Hosted Fields (UHF) refund without hosted_fields_payment_id → clean refusal + // ------------------------------------------------------------------------- + + /** @return iterable}> */ + public static function detailsWithoutAPaymentId(): iterable + { + yield 'key absent' => [['hosted_fields_operation_id' => 'op_1']]; + yield 'empty string' => [['hosted_fields_payment_id' => '', 'hosted_fields_operation_id' => 'op_1']]; + yield 'not a string' => [['hosted_fields_payment_id' => 123, 'hosted_fields_operation_id' => 'op_1']]; + } + + /** @dataProvider detailsWithoutAPaymentId */ + public function testProcess_hostedFields_withoutAPaymentId_refusesWithoutTouchingTheApiOrTheLock( + array $details, + ): void + { + $lock = $this->createMock(ILock::class); + $lock->expects(self::never())->method('acquire'); + $processor = $this->buildProcessor($this->requestStack, $lock); + $payment = $this->buildHostedFieldsPayment($details); + $payment->expects(self::never())->method('setDetails'); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->logger->expects(self::once())->method('error')->with( + self::stringContains('has no hosted_fields_payment_id'), + [ + 'sylius_payment_id' => 42, + 'order_number' => '000000042', + 'hosted_fields_operation_id' => 'op_1', + ], + ); + $this->expectException(UpdateHandlingException::class); + + $processor->process($payment); + } + + /** @dataProvider detailsWithoutAPaymentId */ + public function testProcessWithAmount_hostedFields_withoutAPaymentId_refusesAndRecordsNothing(array $details): void + { + $lock = $this->createMock(ILock::class); + $lock->expects(self::never())->method('acquire'); + $processor = $this->buildProcessor($this->requestStack, $lock); + $payment = $this->buildHostedFieldsPayment($details); + $payment->expects(self::never())->method('setDetails'); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->payplugRefundHistoryRepository->expects(self::never())->method('add'); + $this->logger->expects(self::once())->method('error') + ->with(self::stringContains('has no hosted_fields_payment_id'), self::anything()); + $this->expectException(UpdateHandlingException::class); + + $processor->processWithAmount($payment, 500, 77); + } + + public function testProcess_hostedFields_withoutAPaymentId_tellsTheAdminWhy(): void + { + $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + $requestStack = new RequestStack(); + $requestStack->push($request); + $this->translator->method('trans') + ->with('payplug_sylius_payplug_plugin.ui.payment_refund_missing_payment_id', ['%order%' => '000000042']) + ->willReturn('cannot be refunded'); + $processor = $this->buildProcessor($requestStack, $this->lock); + + try { + $processor->process($this->buildHostedFieldsPayment([])); + self::fail('Expected UpdateHandlingException.'); + } catch (UpdateHandlingException) { + } + + self::assertSame(['cannot be refunded'], $session->getFlashBag()->get('error')); + } + + public function testProcessWithAmount_hostedFields_withoutAPaymentId_tellsTheAdminWhy(): void + { + $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + $requestStack = new RequestStack(); + $requestStack->push($request); + $this->translator->method('trans')->willReturn('cannot be refunded'); + $processor = $this->buildProcessor($requestStack, $this->lock); + + try { + $processor->processWithAmount($this->buildHostedFieldsPayment([]), 500, 77); + self::fail('Expected UpdateHandlingException.'); + } catch (UpdateHandlingException) { + } + + self::assertSame(['cannot be refunded'], $session->getFlashBag()->get('error')); + } + + public function testProcess_hostedFields_withoutAPaymentIdAndWithoutARequest_stillRefusesCleanly(): void + { + $processor = $this->buildProcessor(new RequestStack(), $this->lock); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->expectException(UpdateHandlingException::class); + + $processor->process($this->buildHostedFieldsPayment([])); + } + + public function testProcess_hostedFields_withoutAPaymentIdAndARequestWithoutASession_stillRefusesCleanly(): void + { + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + $processor = $this->buildProcessor($requestStack, $this->lock); + + $this->refundCreator->expects(self::never())->method('createRefund'); + $this->expectException(UpdateHandlingException::class); + + $processor->process($this->buildHostedFieldsPayment([])); + } + + private function buildProcessor(RequestStack $requestStack, ILock $lock): RefundPaymentProcessor + { + return new RefundPaymentProcessor( + $requestStack, + $this->logger, + $this->translator, + $this->refundPaymentRepository, + $this->payplugRefundHistoryRepository, + $this->apiClientFactory, + $this->refundCreator, + $lock, + ); + } + private function buildPayment(string $factoryName, array $details): PaymentInterface&MockObject { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); @@ -256,4 +903,26 @@ private function buildPayment(string $factoryName, array $details): PaymentInter return $payment; } + + private function buildHostedFieldsPayment(array $details): PaymentInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getFactoryName')->willReturn(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->method('getConfig')->willReturn([PayPlugGatewayFactory::HOSTED_FIELDS => true]); + + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('000000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $payment->method('getDetails')->willReturn($details); + $payment->method('getOrder')->willReturn($order); + $payment->method('getAmount')->willReturn(2400); + $payment->method('getId')->willReturn(42); + + return $payment; + } } diff --git a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php index 6e015ea5..bec3d0e7 100644 --- a/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php +++ b/tests/PHPUnit/Provider/SupportedMethodsProviderTest.php @@ -8,9 +8,12 @@ use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface; use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Provider\SupportedMethodsProvider; +use PayPlug\SyliusPayPlugPlugin\Resolver\AccountAmountRangeResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Currency\Context\CurrencyContextInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; @@ -25,15 +28,25 @@ final class SupportedMethodsProviderTest extends TestCase private SupportedMethodsProvider $provider; + /** @var \SplObjectStorage */ + private \SplObjectStorage $clientsByPaymentMethod; + protected function setUp(): void { $this->currencyContext = $this->createMock(CurrencyContextInterface::class); $this->clientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); $this->apiClient = $this->createMock(PayPlugApiClientInterface::class); - $this->clientFactory->method('create')->willReturn($this->apiClient); + // Every payment method resolves to the shared client unless a test gives it one of its own + // through assignAccount(), which is how a second PayPlug account is modelled. + $this->clientsByPaymentMethod = new \SplObjectStorage(); + $this->clientFactory->method('createForPaymentMethod')->willReturnCallback( + fn (object $paymentMethod): PayPlugApiClientInterface => $this->clientsByPaymentMethod->contains($paymentMethod) + ? $this->clientsByPaymentMethod[$paymentMethod] + : $this->apiClient, + ); - $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory); + $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory, new AccountAmountRangeResolver(), new NullLogger()); } // ------------------------------------------------------------------------- @@ -67,6 +80,9 @@ public function testProvide_withDifferentFactory_doesNotFilter(): void /** * The current currency is USD but the method only authorizes EUR. * Verifies the method is removed from the result list. + * + * No $paymentCurrencyCode is passed, so this also covers the documented fallback to + * CurrencyContextInterface for a payment carrying no currency of its own. */ public function testProvide_withUnauthorizedCurrency_removesMethod(): void { @@ -80,6 +96,91 @@ public function testProvide_withUnauthorizedCurrency_removesMethod(): void self::assertEmpty($result); } + /** + * The payment's own currency decides, not the one being displayed. Here the shopper browses in + * EUR (authorized) while the order was placed in USD (not authorized) — the amount is USD, so + * the method must go. Reading the display currency instead would keep a method whose limits + * were never checked against the amount's actual currency. + */ + public function testProvide_withPaymentCurrencyUnauthorized_ignoresDisplayCurrency(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + + $result = $this->provider->provide( + [$method], + PayPlugGatewayFactory::FACTORY_NAME, + 1000, + paymentCurrencyCode: 'USD', + ); + + self::assertEmpty($result); + } + + /** + * The mirror case, and the one that was mis-filtering before: the order is in EUR (authorized, + * amount within bounds) while the shopper has switched the display to USD. The method must be + * kept — previously the USD display currency was compared against a EUR-only account and hid a + * payment method that would have been charged in EUR. + */ + public function testProvide_withPaymentCurrencyAuthorized_keepsMethodDespiteDisplayCurrency(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME); + + $result = $this->provider->provide( + [$method], + PayPlugGatewayFactory::FACTORY_NAME, + 1000, + paymentCurrencyCode: 'EUR', + ); + + self::assertCount(1, $result); + } + + /** + * Same unauthorized-currency setup, but the `payplug` method has Hosted Fields selected. + * UHF is exempt from the currency gate because the Retail `/account` payload does not know a + * UHF account's currencies (see the comment in SupportedMethodsProvider), so the method must + * survive — and, having no advertised limits for USD, must not be amount-filtered either + * despite 1000 sitting outside the EUR 99..2000000 range that the payload does advertise. + */ + public function testProvide_withUnauthorizedCurrencyAndHostedFields_keepsMethod(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(1, $result); + } + + /** + * Hosted Fields is exempt from the currency gate, not from the amount limits: when the active + * currency *is* advertised, its min/max still apply. + */ + public function testProvide_withAuthorizedCurrencyAndHostedFields_stillAppliesAmountLimits(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + ]); + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 50); + + self::assertEmpty($result); + } + // ------------------------------------------------------------------------- // provide() — amount below min_amount → method removed // ------------------------------------------------------------------------- @@ -225,7 +326,7 @@ public function testProvide_withAllowedCountry_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'FR'); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); self::assertCount(1, $result); } @@ -243,7 +344,7 @@ public function testProvide_withDisallowedCountry_removesMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, 'US'); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: 'US'); self::assertEmpty($result); } @@ -261,7 +362,7 @@ public function testProvide_withAllowedCountriesAll_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_bancontact'); - $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, 'US'); + $result = $this->provider->provide([$method], 'payplug_bancontact', 1000, billingCountryCode: 'US'); self::assertCount(1, $result); } @@ -279,7 +380,7 @@ public function testProvide_withNullBillingCountry_keepsMethod(): void $this->apiClient->method('getAccount')->willReturn($account); $method = $this->buildPaymentMethod('payplug_scalapay'); - $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, null); + $result = $this->provider->provide([$method], 'payplug_scalapay', 1000, billingCountryCode: null); self::assertCount(1, $result); } @@ -359,6 +460,199 @@ public function testProvide_fallsBackToConfigurationAmounts(): void self::assertEmpty($result2); } + // ------------------------------------------------------------------------- + // provide() — merchant-configured min/max override the API bounds + // ------------------------------------------------------------------------- + + /** + * The gateway config sets a min_amount (1000) tighter than the API min (99). + * Verifies amounts below the merchant's min are removed, and the merchant's own min boundary is kept. + */ + public function testProvide_withMerchantConfiguredMinAmount_overridesApiMin(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => 1000]); + + $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 500); + self::assertEmpty($result); + + $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 1000); + self::assertCount(1, $result2); + } + + /** + * The gateway config sets a max_amount (100000) tighter than the API max (2000000). + * Verifies amounts above the merchant's max are removed, and the merchant's own max boundary is kept. + */ + public function testProvide_withMerchantConfiguredMaxAmount_overridesApiMax(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 100000]); + + $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); + self::assertEmpty($result); + + $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 100000); + self::assertCount(1, $result2); + } + + /** + * No min_amount/max_amount set in the gateway config (merchant left the fields blank). + * Verifies the API bounds alone still apply, unchanged from today's behavior. + */ + public function testProvide_withoutMerchantConfiguredAmounts_fallsBackToApiBoundsOnly(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); + + $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50); + self::assertEmpty($result); + + $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 99); + self::assertCount(1, $result2); + } + + /** + * The merchant's min_amount/max_amount override is entered as EUR (MoneyType field), but + * checkout is happening in USD. The EUR-denominated override must not be applied to a + * USD amount — only the API's own per-currency bounds apply. + */ + public function testProvide_merchantConfiguredAmountsIgnoredForNonEurCurrency(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('USD'); + + $account = [ + 'configuration' => [ + 'min_amounts' => ['USD' => 100], + 'max_amounts' => ['USD' => 200000], + ], + 'payment_methods' => [], + ]; + $this->apiClient->method('getAccount')->willReturn($account); + + // If wrongly applied to USD, this EUR-denominated max_amount would exclude the payment. + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['max_amount' => 300]); + + $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); + self::assertCount(1, $result); + } + + /** + * The min_amount/max_amount keys are Scalapay's own: only IsScalapayAmountRangeValidValidator + * keeps them inside the API-authorized range at save time, and it is wired for Scalapay only. + * Another gateway carrying the same keys must therefore be left on the raw API bounds rather + * than granted an unvalidated checkout override. + */ + public function testProvide_merchantConfiguredAmountsIgnoredForNonScalapayGateway(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + // If wrongly honored, this max_amount would exclude the payment below. + $method = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, ['min_amount' => 1000, 'max_amount' => 300]); + + $result = $this->provider->provide([$method], PayPlugGatewayFactory::FACTORY_NAME, 150000); + self::assertCount(1, $result); + } + + /** + * The gateway config is a plain serialized array, so a direct DB edit or an import script can + * leave a non-int in it. provide() runs on every checkout page with no surrounding try/catch: + * a malformed override must degrade to the API bounds, not throw and break payment-method + * resolution for the whole checkout. + */ + public function testProvide_withMalformedMerchantConfiguredAmounts_fallsBackToApiBounds(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + $this->apiClient->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + + $method = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, ['min_amount' => '1000', 'max_amount' => 'nonsense']); + + // API bounds are 99–2000000: an in-range amount is kept, an out-of-range one still removed. + $result = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 150000); + self::assertCount(1, $result); + + $result2 = $this->provider->provide([$method], ScalapayGatewayFactory::FACTORY_NAME, 50); + self::assertEmpty($result2); + } + + // ------------------------------------------------------------------------- + // provide() — one account per gateway config, not one per call + // ------------------------------------------------------------------------- + + /** + * Two enabled methods of the same factory sitting on different PayPlug accounts: the account + * resolved for the first must not decide the fate of the second. Here the first authorizes EUR + * and the second only USD, so an EUR checkout keeps the first and drops the second — sharing + * one lookup across the loop kept both. + */ + public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstItsOwnCurrencies(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $eurMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, configId: 1); + $usdMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, configId: 2); + + $this->assignAccount($eurMethod, $this->buildAccount(99, 2000000)); + $this->assignAccount($usdMethod, [ + 'configuration' => ['min_amounts' => ['USD' => 99], 'max_amounts' => ['USD' => 2000000]], + 'payment_methods' => [], + ]); + + $result = $this->provider->provide([$eurMethod, $usdMethod], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(1, $result); + self::assertSame($eurMethod, reset($result)); + } + + /** + * Same split, for the billing-country gate: each method is checked against its own account's + * allowed_countries. + */ + public function testProvide_withMethodsOnDifferentAccounts_filtersEachAgainstItsOwnAllowedCountries(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $frMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 1); + $deMethod = $this->buildPaymentMethod('payplug_scalapay', configId: 2); + + $this->assignAccount($frMethod, $this->buildScalapayAccount(['FR'])); + $this->assignAccount($deMethod, $this->buildScalapayAccount(['DE'])); + + $result = $this->provider->provide([$frMethod, $deMethod], 'payplug_scalapay', 1000, billingCountryCode: 'FR'); + + self::assertCount(1, $result); + self::assertSame($frMethod, reset($result)); + } + + /** + * The per-account lookup must stay memoized: two methods sharing one gateway config hit the + * `/account` endpoint once between them, not once each. + */ + public function testProvide_withMethodsSharingOneGatewayConfig_readsThatAccountOnce(): void + { + $this->currencyContext->method('getCurrencyCode')->willReturn('EUR'); + + $gatewayConfig = $this->buildGatewayConfig(PayPlugGatewayFactory::FACTORY_NAME, [], 1); + $first = $this->buildPaymentMethodFor($gatewayConfig); + $second = $this->buildPaymentMethodFor($gatewayConfig); + + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->expects(self::once())->method('getAccount')->willReturn($this->buildAccount(99, 2000000)); + $this->clientsByPaymentMethod[$first] = $client; + $this->clientsByPaymentMethod[$second] = $client; + + $result = $this->provider->provide([$first, $second], PayPlugGatewayFactory::FACTORY_NAME, 1000); + + self::assertCount(2, $result); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -374,14 +668,69 @@ private function buildAccount(int $minAmount, int $maxAmount): array ]; } - private function buildPaymentMethod(string $factoryName): PaymentMethodInterface + /** + * @param list $allowedCountries + */ + private function buildScalapayAccount(array $allowedCountries): array + { + return [ + 'configuration' => ['min_amounts' => ['EUR' => 30], 'max_amounts' => ['EUR' => 2000000]], + 'payment_methods' => ['scalapay' => [ + 'min_amounts' => ['EUR' => 500], + 'max_amounts' => ['EUR' => 200000], + 'allowed_countries' => $allowedCountries, + ]], + ]; + } + + /** + * @param array $config Persisted gateway config; defaults to empty, which is + * neither integrated_payment nor hosted_fields. + */ + private function buildPaymentMethod( + string $factoryName, + array $config = [], + int|string|null $configId = null, + ): PaymentMethodInterface + { + return $this->buildPaymentMethodFor($this->buildGatewayConfig($factoryName, $config, $configId)); + } + + /** + * @param array $config + */ + private function buildGatewayConfig( + string $factoryName, + array $config = [], + int|string|null $configId = null, + ): GatewayConfigInterface { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); + $gatewayConfig->method('getId')->willReturn($configId); + return $gatewayConfig; + } + + private function buildPaymentMethodFor(GatewayConfigInterface $gatewayConfig): PaymentMethodInterface + { $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); return $method; } + + /** + * Puts $paymentMethod on a PayPlug account of its own, as a second configured gateway would be. + * + * @param array $account + */ + private function assignAccount(PaymentMethodInterface $paymentMethod, array $account): void + { + $client = $this->createMock(PayPlugApiClientInterface::class); + $client->method('getAccount')->willReturn($account); + + $this->clientsByPaymentMethod[$paymentMethod] = $client; + } } diff --git a/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php b/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php new file mode 100644 index 00000000..31dc7520 --- /dev/null +++ b/tests/PHPUnit/Resolver/AccountAmountRangeResolverTest.php @@ -0,0 +1,120 @@ +resolver = new AccountAmountRangeResolver(); + } + + public function testResolve_withoutPaymentMethodKey_usesConfigurationDefaults(): void + { + $account = [ + 'configuration' => [ + 'min_amounts' => ['EUR' => 100, 'USD' => 200], + 'max_amounts' => ['EUR' => 100000, 'USD' => 200000], + ], + ]; + + $result = $this->resolver->resolve($account, null); + + self::assertSame([ + 'EUR' => ['min_amount' => 100, 'max_amount' => 100000], + 'USD' => ['min_amount' => 200, 'max_amount' => 200000], + ], $result); + } + + public function testResolve_withPaymentMethodOverride_usesOverrideInsteadOfDefaults(): void + { + $account = [ + 'configuration' => [ + 'min_amounts' => ['EUR' => 30], + 'max_amounts' => ['EUR' => 2000000], + ], + 'payment_methods' => [ + 'scalapay' => [ + 'min_amounts' => ['EUR' => 500], + 'max_amounts' => ['EUR' => 200000], + ], + ], + ]; + + $result = $this->resolver->resolve($account, 'scalapay'); + + self::assertSame(['EUR' => ['min_amount' => 500, 'max_amount' => 200000]], $result); + } + + public function testResolve_withPaymentMethodKeyButNoOverride_fallsBackToConfigurationDefaults(): void + { + $account = [ + 'configuration' => [ + 'min_amounts' => ['EUR' => 30], + 'max_amounts' => ['EUR' => 2000000], + ], + 'payment_methods' => [ + 'apple_pay' => [ + 'enabled' => true, + // no min_amounts / max_amounts + ], + ], + ]; + + $result = $this->resolver->resolve($account, 'apple_pay'); + + self::assertSame(['EUR' => ['min_amount' => 30, 'max_amount' => 2000000]], $result); + } + + /** + * The per-payment-method override is present but the wrong shape (a string, not an array). + * Verifies this degrades gracefully to the configuration defaults instead of blowing up on + * a malformed API response — this is the divergence the two original, independent + * implementations of this parsing logic used to disagree on. + */ + public function testResolve_withMalformedOverride_fallsBackToConfigurationDefaults(): void + { + $account = [ + 'configuration' => [ + 'min_amounts' => ['EUR' => 30], + 'max_amounts' => ['EUR' => 2000000], + ], + 'payment_methods' => [ + 'scalapay' => [ + 'min_amounts' => 'not-an-array', + 'max_amounts' => 'not-an-array', + ], + ], + ]; + + $result = $this->resolver->resolve($account, 'scalapay'); + + self::assertSame(['EUR' => ['min_amount' => 30, 'max_amount' => 2000000]], $result); + } + + public function testResolve_currencyMissingFromMaxAmounts_isExcluded(): void + { + $account = [ + 'configuration' => [ + 'min_amounts' => ['EUR' => 100, 'USD' => 200], + 'max_amounts' => ['EUR' => 100000], + ], + ]; + + $result = $this->resolver->resolve($account, null); + + self::assertSame(['EUR' => ['min_amount' => 100, 'max_amount' => 100000]], $result); + } + + public function testResolve_missingConfiguration_returnsEmptyArray(): void + { + self::assertSame([], $this->resolver->resolve([], null)); + } +} diff --git a/tests/PHPUnit/Resolver/SelectedCardResolverTest.php b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php new file mode 100644 index 00000000..b5e49250 --- /dev/null +++ b/tests/PHPUnit/Resolver/SelectedCardResolverTest.php @@ -0,0 +1,67 @@ +setSession(new Session(new MockArraySessionStorage())); + + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + + $this->resolver = new SelectedCardResolver($this->requestStack, $this->payplugCardRepository); + } + + public function testResolve_withNoCardIdInSession_returnsNull(): void + { + self::assertNull($this->resolver->resolve()); + } + + public function testResolve_withOtherCardSentinelSelected_returnsNull(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', 'other'); + + self::assertNull($this->resolver->resolve()); + } + + public function testResolve_withSelectedCardIdFound_returnsTheCard(): void + { + $card = new Card(); + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn($card); + + self::assertSame($card, $this->resolver->resolve()); + } + + public function testResolve_withSelectedCardIdNoLongerFound_returnsNull(): void + { + $this->requestStack->getSession()->set('payplug_payment_method', self::SELECTED_CARD_ID); + $this->payplugCardRepository->method('find')->with(self::SELECTED_CARD_ID)->willReturn(null); + + self::assertNull($this->resolver->resolve()); + } +} diff --git a/tests/PHPUnit/Support/FakePayplugHttpRequest.php b/tests/PHPUnit/Support/FakePayplugHttpRequest.php new file mode 100644 index 00000000..57e7d955 --- /dev/null +++ b/tests/PHPUnit/Support/FakePayplugHttpRequest.php @@ -0,0 +1,68 @@ + */ + private array $bodies; + + private int $callCount = 0; + + /** + * @param list $bodies raw response bodies, in the order the SDK will request them + */ + public function __construct(array $bodies, private int $httpStatus = 200) + { + $this->bodies = [] === $bodies ? ['{}'] : $bodies; + } + + public function setopt($option, $value) + { + return true; + } + + public function exec() + { + $body = $this->bodies[$this->callCount] ?? $this->bodies[\count($this->bodies) - 1]; + ++$this->callCount; + + return $body; + } + + public function getinfo($option) + { + return $this->httpStatus; + } + + public function close() + { + } + + public function error() + { + return ''; + } + + public function errno() + { + return 0; + } + + public function callCount(): int + { + return $this->callCount; + } +} diff --git a/tests/PHPUnit/Twig/AuthorizationExtensionTest.php b/tests/PHPUnit/Twig/AuthorizationExtensionTest.php new file mode 100644 index 00000000..fac0699a --- /dev/null +++ b/tests/PHPUnit/Twig/AuthorizationExtensionTest.php @@ -0,0 +1,99 @@ + + */ + public static function inputNotations(): iterable + { + yield 'EUR' => ['EUR', '1234.56']; + yield 'USD' => ['USD', '1234.56']; + // A typed amount would be refused in these currencies, so no placeholder suggests one. + yield 'zero-decimal JPY' => ['JPY', '']; + yield 'three-decimal KWD' => ['KWD', '']; + yield 'empty currency' => ['', '']; + yield 'malformed currency' => ['EU', '']; + } + + /** + * @dataProvider inputNotations + */ + public function testDescribe_exposesTheRemainingAmountInTheNotationTheAmountFieldsAccept( + string $currency, + string $expected, + ): void + { + $description = $this->extension()->describe($this->authorizedPayment(123456, $currency)); + + self::assertNotNull($description); + self::assertSame($expected, $description['remaining_amount_input']); + self::assertSame(123456, $description['remaining_amount']); + } + + private function extension(): AuthorizationExtension + { + $clock = new MockClock('2026-09-24T10:00:00+00:00'); + + return new AuthorizationExtension( + new AuthorizedPaymentOperationProcessor( + $this->createMock(AuthorizationOperatorInterface::class), + $this->createMock(ILock::class), + $this->createMock(EntityManagerInterface::class), + $this->createMock(StateMachineInterface::class), + $clock, + $this->createMock(LoggerInterface::class), + ), + $clock, + ); + } + + private function authorizedPayment(int $amount, string $currency): Payment + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_1', + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + ]); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $payment = new Payment(); + $payment->setOrder(new Order()); + $payment->setMethod($method); + $payment->setAmount($amount); + $payment->setCurrencyCode($currency); + $payment->setState(PaymentInterface::STATE_AUTHORIZED); + $payment->setDetails(AuthorizationDetails::open( + ['hosted_fields_payment_id' => 'pay_1'], + new PaymentOutput(201, '{}', null, null, null, '2026-09-30T10:00:00+00:00', $amount), + $amount, + )); + + return $payment; + } +} diff --git a/tests/PHPUnit/Twig/PayPlugExtensionTest.php b/tests/PHPUnit/Twig/PayPlugExtensionTest.php new file mode 100644 index 00000000..703c5089 --- /dev/null +++ b/tests/PHPUnit/Twig/PayPlugExtensionTest.php @@ -0,0 +1,72 @@ +canSaveCardChecker = $this->createMock(CanSaveCardCheckerInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + $this->extension = new PayPlugExtension( + $this->canSaveCardChecker, + $this->apiClientFactory, + 'https://sdk.example.test/hosted-fields.min.js', + ); + } + + public function testHostedFieldsSdkUrl_returnsTheConfiguredUrl(): void + { + self::assertSame('https://sdk.example.test/hosted-fields.min.js', $this->extension->hostedFieldsSdkUrl()); + } + + public function testGetFunctions_registersThePayplugHostedFieldsSdkUrlFunction(): void + { + $functions = []; + foreach ($this->extension->getFunctions() as $function) { + $functions[$function->getName()] = $function; + } + + self::assertArrayHasKey('payplug_hosted_fields_sdk_url', $functions); + $callable = $functions['payplug_hosted_fields_sdk_url']->getCallable(); + self::assertIsCallable($callable); + self::assertSame('https://sdk.example.test/hosted-fields.min.js', $callable()); + } + + public function testHostedFieldsCompanyId_returnsCompanyIdFromAccount(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willReturn(['company_ref' => 'cmp_abc123']); + $this->apiClientFactory->method('createForPaymentMethod')->with($paymentMethod)->willReturn($apiClient); + + self::assertSame('cmp_abc123', $this->extension->hostedFieldsCompanyId($paymentMethod)); + } + + public function testHostedFieldsCompanyId_missingCompanyIdKey_returnsEmptyString(): void + { + $paymentMethod = $this->createMock(PaymentMethodInterface::class); + $apiClient = $this->createMock(PayPlugApiClientInterface::class); + $apiClient->method('getAccount')->willReturn([]); + $this->apiClientFactory->method('createForPaymentMethod')->with($paymentMethod)->willReturn($apiClient); + + self::assertSame('', $this->extension->hostedFieldsCompanyId($paymentMethod)); + } +} diff --git a/tests/PHPUnit/Upc/AuthorizationDetailsTest.php b/tests/PHPUnit/Upc/AuthorizationDetailsTest.php new file mode 100644 index 00000000..21371de9 --- /dev/null +++ b/tests/PHPUnit/Upc/AuthorizationDetailsTest.php @@ -0,0 +1,110 @@ + 'pay_1'], $output, 1000); + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame('pay_1', $details['hosted_fields_payment_id']); + self::assertTrue($authorization->isDeferred()); + // The response's amount wins over the Sylius one: the issuer may authorize less. + self::assertSame(900, $authorization->authorizedAmount()); + self::assertSame(900, $authorization->remainingAmount()); + self::assertEquals(new \DateTimeImmutable('2026-10-01T12:00:00+00:00'), $authorization->maxCaptureDate()); + self::assertSame(0, $authorization->version()); + } + + public function testOpen_fallsBackToThePaymentAmountWhenTheResponseCarriesNone(): void + { + // A 3DS-pending response: the authorization isn't granted yet, so it carries no amount. + $output = new PaymentOutput(201, '{}', null, '
', null); + + $authorization = AuthorizationDetails::fromDetails(AuthorizationDetails::open([], $output, 1000)); + + self::assertSame(1000, $authorization->authorizedAmount()); + self::assertNull($authorization->maxCaptureDate()); + } + + public function testRemainingAmount_subtractsCapturesAndCancellationsButNotFailedOnes(): void + { + $details = $this->openedDetails(1000); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c2', 200); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 100); + $details = AuthorizationDetails::withOperationFailed($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c2'); + + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame(300, $authorization->capturedAmount()); + self::assertSame(100, $authorization->cancelledAmount()); + self::assertSame(600, $authorization->remainingAmount()); + self::assertTrue($authorization->hasCaptures()); + // A failed operation still counts toward the version: it was attempted, and a form built + // before it is stale either way. + self::assertSame(3, $authorization->version()); + } + + public function testFindOperation_tellsCapturesAndCancellationsApart(): void + { + $details = $this->openedDetails(1000); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CAPTURE, 'op_c1', 300); + $details = AuthorizationDetails::withOperation($details, AuthorizationDetails::OPERATION_CANCELLATION, 'op_v1', 100); + + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertSame(['operation' => 'capture', 'amount' => 300], $authorization->findOperation('op_c1')); + self::assertSame(['operation' => 'cancellation', 'amount' => 100], $authorization->findOperation('op_v1')); + self::assertNull($authorization->findOperation('op_unknown')); + } + + public function testResolveCreationOutcome_readsSuccessAsAuthorizedOnlyForADeferredPayment(): void + { + $deferred = AuthorizationDetails::fromDetails($this->openedDetails(1000)); + $immediate = AuthorizationDetails::fromDetails([]); + + self::assertSame(PaymentOutcome::AUTHORIZED, $deferred->resolveCreationOutcome(PaymentOutcome::PAID)); + self::assertSame(PaymentOutcome::FAILED, $deferred->resolveCreationOutcome(PaymentOutcome::FAILED)); + self::assertSame(PaymentOutcome::PAID, $immediate->resolveCreationOutcome(PaymentOutcome::PAID)); + } + + public function testExpiry_isComputedAgainstTheDeadline(): void + { + $details = [...$this->openedDetails(1000), AuthorizationDetails::MAX_CAPTURE_DATE => '2026-10-01T12:00:00+00:00']; + $authorization = AuthorizationDetails::fromDetails($details); + + self::assertFalse($authorization->isExpired(new \DateTimeImmutable('2026-09-25T12:00:00+00:00'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2026-09-25T12:00:00+00:00'))); + self::assertTrue($authorization->isExpiringSoon(new \DateTimeImmutable('2026-09-30T13:00:00+00:00'))); + self::assertTrue($authorization->isExpired(new \DateTimeImmutable('2026-10-01T12:00:00+00:00'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2026-10-02T00:00:00+00:00'))); + } + + public function testExpiry_isNeverAssumedWithoutAKnownDeadline(): void + { + $authorization = AuthorizationDetails::fromDetails($this->openedDetails(1000)); + + self::assertFalse($authorization->isExpired(new \DateTimeImmutable('2100-01-01'))); + self::assertFalse($authorization->isExpiringSoon(new \DateTimeImmutable('2100-01-01'))); + } + + /** + * @return mixed[] + */ + private function openedDetails(int $amount): array + { + return AuthorizationDetails::open([], new PaymentOutput(201, '{}', null, null, null, null, $amount), $amount); + } +} diff --git a/tests/PHPUnit/Upc/AuthorizationWebhookBodyTest.php b/tests/PHPUnit/Upc/AuthorizationWebhookBodyTest.php new file mode 100644 index 00000000..ce6b9329 --- /dev/null +++ b/tests/PHPUnit/Upc/AuthorizationWebhookBodyTest.php @@ -0,0 +1,25 @@ + 1], AuthorizationWebhookBody::withMaxCaptureDate(['a' => 1], 'not json')); + } +} diff --git a/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php new file mode 100644 index 00000000..bb6208f2 --- /dev/null +++ b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php @@ -0,0 +1,182 @@ + [ + 'id' => 'card_xxx', + 'card' => [ + 'network' => 'VISA', + 'code6x4' => '424242XXXXXX4242', + ], + 'details' => [ + 'selectedBrand' => 'VISA', + 'validityDate' => '2027-12', + ], + ], + ]); + + self::assertSame([ + 'aliasId' => 'card_xxx', + 'brand' => 'VISA', + 'last4' => '4242', + 'expirationYear' => 2027, + 'expirationMonth' => 12, + ], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_whenCardNetworkAndDetailsSelectedBrandDisagree_cardNetworkWins(): void + { + $body = json_encode([ + 'paymentMethod' => [ + 'card' => ['network' => 'VISA'], + 'details' => ['selectedBrand' => 'MASTERCARD'], + ], + ]); + + self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withNoCardNetwork_fallsBackToDetailsSelectedBrand(): void + { + $body = json_encode([ + 'paymentMethod' => [ + 'card' => ['code6x4' => '424242XXXXXX4242'], + 'details' => ['selectedBrand' => 'MASTERCARD'], + ], + ]); + + $result = CardDataFromPaymentMethodExtractor::extract($body); + + self::assertSame('MASTERCARD', $result['brand']); + } + + public function testExtract_withNonArrayBody_returnsEmptyArray(): void + { + self::assertSame([], CardDataFromPaymentMethodExtractor::extract('"just a string"')); + } + + public function testExtract_withPaymentMethodKeyMissing_returnsEmptyArray(): void + { + self::assertSame([], CardDataFromPaymentMethodExtractor::extract(json_encode(['id' => 'op_1']))); + } + + public function testExtract_withCardKeyMissing_returnsOnlyAliasId(): void + { + $body = json_encode(['paymentMethod' => ['id' => 'card_xxx', 'details' => ['selectedBrand' => 'VISA']]]); + + self::assertSame(['aliasId' => 'card_xxx', 'brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withDetailsKeyMissing_returnsOnlyCardFields(): void + { + $body = json_encode(['paymentMethod' => ['card' => ['network' => 'VISA']]]); + + self::assertSame(['brand' => 'VISA'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withEmptyAliasId_omitsAliasId(): void + { + $body = json_encode(['paymentMethod' => ['id' => '']]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withCode6x4ShorterThanFourCharacters_omitsLast4(): void + { + $body = json_encode(['paymentMethod' => ['card' => ['code6x4' => '42']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withValidityDateNotMatchingTheExpectedFormat_omitsExpiration(): void + { + $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '1225']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withValidityDateOutOfRangeMonth_omitsExpiration(): void + { + $body = json_encode(['paymentMethod' => ['details' => ['validityDate' => '2027-13']]]); + + self::assertSame([], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withStoredId_returnsItAsTheAliasId(): void + { + $body = json_encode(['paymentMethod' => ['storedId' => 'card_stored']]); + + self::assertSame(['aliasId' => 'card_stored'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtract_withBothStoredIdAndId_prefersStoredId(): void + { + $body = json_encode(['paymentMethod' => ['storedId' => 'card_stored', 'id' => 'card_other']]); + + self::assertSame(['aliasId' => 'card_stored'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + /** @dataProvider unusableStoredIds */ + public function testExtract_withAnUnusableStoredId_fallsBackToId(mixed $storedId): void + { + $body = json_encode(['paymentMethod' => ['storedId' => $storedId, 'id' => 'card_xxx']]); + + self::assertSame(['aliasId' => 'card_xxx'], CardDataFromPaymentMethodExtractor::extract($body)); + } + + /** @return iterable */ + public static function unusableStoredIds(): iterable + { + yield 'empty string' => ['']; + yield 'null' => [null]; + yield 'integer' => [123]; + yield 'array' => [['card_stored']]; + } + + public function testExtract_withNeitherStoredIdNorId_returnsNoAliasId(): void + { + self::assertSame([], CardDataFromPaymentMethodExtractor::extract(json_encode(['paymentMethod' => ['card' => []]]))); + } + + public function testExtract_withTheRealWebhookShape_extractsTheAliasAndTheCardFields(): void + { + $body = json_encode([ + 'operationType' => 'PAYMENT', + 'paymentMethod' => [ + 'storedId' => 'card_new_1', + 'card' => ['network' => 'VISA', 'type' => 'VISA', 'code6x4' => '446421XXXXXX0000'], + 'details' => ['fullName' => 'John Doe', 'validityDate' => '2030-12', 'selectedBrand' => 'VISA'], + ], + 'orderId' => '42', + 'id' => '715ac841-1111-4111-8111-111111111111', + ]); + + self::assertSame([ + 'aliasId' => 'card_new_1', + 'brand' => 'VISA', + 'last4' => '0000', + 'expirationYear' => 2030, + 'expirationMonth' => 12, + ], CardDataFromPaymentMethodExtractor::extract($body)); + } + + public function testExtractFromDecoded_withAnAlreadyDecodedBody_behavesLikeExtract(): void + { + $decoded = ['paymentMethod' => ['id' => 'card_xxx', 'card' => ['network' => 'VISA']]]; + + self::assertSame( + ['aliasId' => 'card_xxx', 'brand' => 'VISA'], + CardDataFromPaymentMethodExtractor::extractFromDecoded($decoded), + ); + } +} diff --git a/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php new file mode 100644 index 00000000..03488faa --- /dev/null +++ b/tests/PHPUnit/Upc/GatewayCredentialsResolverTest.php @@ -0,0 +1,84 @@ +createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method)); + } + + /** + * The submerchant is no longer configurable here — it belongs to the EUR MID configurations, + * not to the multi-currency ones this flow targets — so a config left over from before the + * field was removed must resolve exactly like one without it rather than throwing. + */ + public function testResolve_withALeftoverSubmerchantIdInConfig_ignoresIt(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => 'acct_123', + 'hfSubMerchantId' => 'submerchant_123', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + self::assertSame('acct_123', GatewayCredentialsResolver::resolve($method)); + } + + public function testResolve_withNoGatewayConfig_throws(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn(null); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } + + public function testResolve_withMissingAccountId_throws(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } + + public function testResolve_withBlankAccountId_throws(): void + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HF_IDENTIFIER => '', + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $this->expectException(\LogicException::class); + + GatewayCredentialsResolver::resolve($method); + } +} diff --git a/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php new file mode 100644 index 00000000..2e3831b0 --- /dev/null +++ b/tests/PHPUnit/Upc/IntegrationDescriptionProviderTest.php @@ -0,0 +1,35 @@ +factory = new OrderAddressDtoCreator(); + } + + private function address( + ?string $phoneNumber = null, + string $countryCode = 'FR', + string $provinceCode = '75', + ): AddressInterface&MockObject { + $address = $this->createMock(AddressInterface::class); + $address->method('getFirstName')->willReturn('Jane'); + $address->method('getLastName')->willReturn('Doe'); + $address->method('getStreet')->willReturn('10 Rue de Rivoli'); + $address->method('getCity')->willReturn('Paris'); + $address->method('getCountryCode')->willReturn($countryCode); + $address->method('getProvinceCode')->willReturn($provinceCode); + $address->method('getPostcode')->willReturn('75001'); + $address->method('getCompany')->willReturn('Acme Corp'); + $address->method('getPhoneNumber')->willReturn($phoneNumber); + + return $address; + } + + private function orderWithAddresses( + ?AddressInterface $billing, + ?AddressInterface $shipping, + ?string $customerEmail = 'jane@example.com', + string $customerGender = '', + ): OrderInterface&MockObject { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getEmail')->willReturn($customerEmail); + $customer->method('getGender')->willReturn($customerGender); + + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + $order->method('getBillingAddress')->willReturn($billing); + $order->method('getShippingAddress')->willReturn($shipping); + + return $order; + } + + public function testCreateBilling_withNoBillingAddress_returnsNull(): void + { + $order = $this->orderWithAddresses(null, null); + + self::assertNull($this->factory->createBilling($order)); + } + + public function testCreateShipping_withNoShippingAddress_returnsNull(): void + { + $order = $this->orderWithAddresses(null, null); + + self::assertNull($this->factory->createShipping($order)); + } + + public function testCreateBilling_withAFullAddressAndMobilePhone_mapsEveryField(): void + { + $order = $this->orderWithAddresses($this->address('+33612345678'), null, customerGender: 'f'); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertSame('Jane', $billing->contact->firstName); + self::assertSame('Doe', $billing->contact->lastName); + self::assertSame('MRS', $billing->title); + self::assertNull($billing->contact->phone); + self::assertSame('+33612345678', $billing->contact->mobilePhone); + self::assertNotNull($billing->address); + self::assertSame('10 Rue de Rivoli', $billing->address->line); + self::assertSame('Paris', $billing->address->city); + self::assertSame('FR', $billing->address->country); + self::assertSame('75', $billing->address->state); + self::assertSame('75001', $billing->address->zipCode); + } + + public function testCreateBilling_withAProvinceCodeLongerThanThreeChars_omitsState(): void + { + $address = $this->address(provinceCode: 'US-CA'); + $order = $this->orderWithAddresses($address, null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->address); + self::assertNull($billing->address->state); + } + + public function testCreateBilling_withALandlinePhone_setsPhoneNotMobilePhone(): void + { + $order = $this->orderWithAddresses($this->address('+33142345678'), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertSame('+33142345678', $billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withNoPhoneNumber_leavesBothPhoneFieldsNull(): void + { + $order = $this->orderWithAddresses($this->address(null), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertNull($billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withAnUnparseablePhoneNumber_leavesBothPhoneFieldsNullInsteadOfThrowing(): void + { + $order = $this->orderWithAddresses($this->address('not-a-phone-number'), null); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNotNull($billing->contact); + self::assertNull($billing->contact->phone); + self::assertNull($billing->contact->mobilePhone); + } + + public function testCreateBilling_withMaleGender_mapsTitleToMr(): void + { + $order = $this->orderWithAddresses($this->address(), null, customerGender: 'm'); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertSame('MR', $billing->title); + } + + public function testCreateBilling_withNoGender_leavesTitleNull(): void + { + $order = $this->orderWithAddresses($this->address(), null, customerGender: ''); + + $billing = $this->factory->createBilling($order); + + self::assertNotNull($billing); + self::assertNull($billing->title); + } + + public function testCreateShipping_withAFullAddress_mapsEveryFieldIncludingCustomerEmailAndCompany(): void + { + $order = $this->orderWithAddresses(null, $this->address('+33612345678'), customerEmail: 'jane@example.com'); + + $shipping = $this->factory->createShipping($order); + + self::assertNotNull($shipping); + self::assertNotNull($shipping->contact); + self::assertSame('Jane', $shipping->contact->firstName); + self::assertSame('Doe', $shipping->contact->lastName); + self::assertSame('jane@example.com', $shipping->email); + self::assertSame('Acme Corp', $shipping->companyName); + self::assertNull($shipping->contact->phone); + self::assertSame('+33612345678', $shipping->contact->mobilePhone); + self::assertNotNull($shipping->address); + self::assertSame('75001', $shipping->address->zipCode); + } + + public function testCreateBillingAndCreateShipping_areIndependentOfEachOther(): void + { + $order = $this->orderWithAddresses($this->address('+33612345678'), null); + + self::assertNotNull($this->factory->createBilling($order)); + self::assertNull($this->factory->createShipping($order)); + } +} diff --git a/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php new file mode 100644 index 00000000..8615f31e --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php @@ -0,0 +1,333 @@ +urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->afterPayUrlProvider = $this->createMock(UrlProviderInterface::class); + $this->afterPayUrlProvider->method('getUrl')->willReturn('https://shop.test/order/00000042/pay'); + $this->requestStack = new RequestStack(); + + $this->builder = new PaymentCaptureContextBuilder( + $this->urlGenerator, + $this->afterPayUrlProvider, + new OrderAddressDtoCreator(), + $this->requestStack, + ); + } + + private function methodWithGatewayConfig(?array $config): PaymentMethodInterface&MockObject + { + $method = $this->createMock(PaymentMethodInterface::class); + if (null !== $config) { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + } + + return $method; + } + + public function testResolveGatewayCredentials_withCompleteConfig_returnsAccountId(): void + { + $method = $this->methodWithGatewayConfig(['hfIdentifier' => 'acct_123']); + + self::assertSame('acct_123', $this->builder->resolveGatewayCredentials($method)); + } + + public function testResolveGatewayCredentials_withNoGatewayConfig_throws(): void + { + $this->expectException(\LogicException::class); + + $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(null)); + } + + public function testResolveGatewayCredentials_withBlankAccountId_throws(): void + { + $this->expectException(\LogicException::class); + + $this->builder->resolveGatewayCredentials($this->methodWithGatewayConfig(['hfIdentifier' => ''])); + } + + public function testResolvePaymentMethod_withNoMethodOnThePayment_throws(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->builder->resolvePaymentMethod($payment); + } + + public function testResolvePaymentMethod_withAMethodOnThePayment_returnsIt(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getMethod')->willReturn($method); + + self::assertSame($method, $this->builder->resolvePaymentMethod($payment)); + } + + public function testResolveAmountAndCurrency_withAmountOrCurrencyMissing_throws(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn(null); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + $this->expectException(\LogicException::class); + + $this->builder->resolveAmountAndCurrency($payment); + } + + public function testResolveAmountAndCurrency_withBothSet_returnsThem(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getAmount')->willReturn(1000); + $payment->method('getCurrencyCode')->willReturn('EUR'); + + self::assertSame([1000, 'EUR'], $this->builder->resolveAmountAndCurrency($payment)); + } + + public function testBuildCustomerDto_withNoCustomerOnTheOrder_throws(): void + { + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->builder->buildCustomerDto($order); + } + + public function testBuildCustomerDto_withNoCustomerEmail_throws(): void + { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getEmail')->willReturn(null); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $this->expectException(\LogicException::class); + + $this->builder->buildCustomerDto($order); + } + + public function testBuildCustomerDto_withCustomerAndEmail_returnsCustomerDto(): void + { + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('customer@example.com'); + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $dto = $this->builder->buildCustomerDto($order); + + self::assertSame('7', $dto->id); + self::assertSame('customer@example.com', $dto->email); + } + + public function testBuildBrowserDto_withNoCurrentRequest_returnsNull(): void + { + self::assertNull($this->builder->buildBrowserDto()); + } + + public function testBuildBrowserDto_withACurrentRequest_returnsItsClientDetails(): void + { + $request = new Request(server: ['REMOTE_ADDR' => '203.0.113.5']); + $request->headers->set('referer', 'https://shop.test/checkout'); + $request->headers->set('User-Agent', 'TestAgent/1.0'); + $this->requestStack->push($request); + + $dto = $this->builder->buildBrowserDto(); + + self::assertNotNull($dto); + self::assertSame('203.0.113.5', $dto->ip); + self::assertSame('https://shop.test/checkout', $dto->referrer); + self::assertSame('TestAgent/1.0', $dto->userAgent); + } + + public function testBuildCommonFields_setsSuccessCancelAndNotificationUrls(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); + + self::assertSame('acct_123', $common->accountId); + self::assertSame(1000, $common->amount); + self::assertSame('EUR', $common->currency); + self::assertSame('00000042', $common->orderId); + self::assertNull($common->submerchantExternalId); + self::assertSame('https://shop.test/payplug/notify/abc', $common->notificationUrl); + self::assertSame('https://shop.test/order/00000042/pay', $common->successUrl); + self::assertSame('https://shop.test/order/00000042/pay?status=canceled', $common->cancelUrl); + } + + public function testBuildCommonFields_withNoOrder_fallsBackToPaymentIdAsOrderId(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, null); + + self::assertSame('42', $common->orderId); + self::assertNull($common->billing); + self::assertNull($common->shipping); + } + + public function testBuildCommonFields_capturesImmediatelyUnlessDeferredCaptureIsEnabledOnHostedFields(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + self::assertTrue($this->commonFieldsForGatewayConfig([PayPlugGatewayFactory::HOSTED_FIELDS => true])->capture); + self::assertFalse($this->commonFieldsForGatewayConfig([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::DEFERRED_CAPTURE => true, + ])->capture); + } + + /** + * @param array $config + */ + private function commonFieldsForGatewayConfig(array $config): CommonFieldsDto + { + $gatewayConfig = new GatewayConfig(); + $gatewayConfig->setFactoryName(PayPlugGatewayFactory::FACTORY_NAME); + $gatewayConfig->setConfig($config); + $method = new PaymentMethod(); + $method->setGatewayConfig($gatewayConfig); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getMethod')->willReturn($method); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + return $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, null); + } + + public function testBuildCommonFields_withAnOrderItem_usesItsProductNameAsDescription(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $item = $this->createMock(OrderItemInterface::class); + $item->method('getProductName')->willReturn('Blue T-Shirt'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getItems')->willReturn(new ArrayCollection([$item])); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); + + self::assertSame('Blue T-Shirt', $common->description); + } + + public function testBuildCommonFields_withNoOrderItem_fallsBackToTheIntegrationDescription(): void + { + $this->urlGenerator->method('generate')->willReturn('https://shop.test/payplug/notify/abc'); + + $order = $this->createMock(OrderInterface::class); + $order->method('getNumber')->willReturn('00000042'); + $order->method('getItems')->willReturn(new ArrayCollection()); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + $paymentRequest->method('getHash')->willReturn(Uuid::v4()); + + $common = $this->builder->buildCommonFields('acct_123', 1000, 'eur', $paymentRequest, $order); + + self::assertNotNull($common->description); + self::assertNotSame('Blue T-Shirt', $common->description); + } + + public function testResolveFullNameForCardDetails_withABillingAddressFullName_returnsIt(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn('Jane Doe'); + $order = $this->createMock(OrderInterface::class); + $order->method('getBillingAddress')->willReturn($billingAddress); + + self::assertSame('Jane Doe', $this->builder->resolveFullNameForCardDetails($order)); + } + + public function testResolveFullNameForCardDetails_withNoBillingAddressFullName_fallsBackToTheCustomerFullName(): void + { + $billingAddress = $this->createMock(AddressInterface::class); + $billingAddress->method('getFullName')->willReturn(''); + $customer = $this->createMock(CustomerInterface::class); + $customer->method('getFullName')->willReturn('Jane Customer'); + $order = $this->createMock(OrderInterface::class); + $order->method('getBillingAddress')->willReturn($billingAddress); + $order->method('getCustomer')->willReturn($customer); + + self::assertSame('Jane Customer', $this->builder->resolveFullNameForCardDetails($order)); + } + + public function testResolveFullNameForCardDetails_withNoNameAvailableAnywhere_returnsNull(): void + { + $order = $this->createMock(OrderInterface::class); + + self::assertNull($this->builder->resolveFullNameForCardDetails($order)); + } +} diff --git a/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php new file mode 100644 index 00000000..e940fb30 --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php @@ -0,0 +1,250 @@ +logger = $this->createMock(LoggerInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + + // A real Session/FlashBag rather than a mock: the assertions below are about what the + // shopper actually ends up seeing, which is the flash bag's contents. + $this->session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($this->session); + $this->requestStack = new RequestStack(); + $this->requestStack->push($request); + + $this->applier = $this->createApplier($this->requestStack); + } + + private function createApplier(RequestStack $requestStack): PaymentCaptureOutcomeApplier + { + return new PaymentCaptureOutcomeApplier( + $this->logger, + $this->stateMachine, + $this->orderStateMutator, + $requestStack, + ); + } + + public function testFailPaymentRequest_tellsTheShopperTheTransactionDidNotGoThrough(): void + { + $this->applier->failPaymentRequest( + $this->createMock(PaymentRequestInterface::class), + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); + + self::assertSame( + [self::SHOPPER_ERROR_FLASH_KEY], + $this->session->getFlashBag()->get('error'), + ); + } + + public function testFailPaymentRequest_neverLeaksTheExceptionMessageToTheShopper(): void + { + // Real example from a UPC 403: the raw message names internal infrastructure and the + // account's configuration, so it must stay in the log and out of the flash bag. + $leaky = 'The IP address "10.204.92.13" is not allowed to access this account.'; + + $this->applier->failPaymentRequest( + $this->createMock(PaymentRequestInterface::class), + $this->createMock(PaymentInterface::class), + new \RuntimeException($leaky), + PaymentCaptureFlow::Alias, + ); + + self::assertSame([self::SHOPPER_ERROR_FLASH_KEY], $this->session->getFlashBag()->get('error')); + } + + public function testFailPaymentRequest_withoutASession_stillFailsThePaymentRequest(): void + { + // Reachable from the CLI (UpdatePaymentStateCommand) and from worker contexts, where + // Request::getSession() would throw — a failed payment must not become a 500 because + // there was nowhere to put a flash message. + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->createApplier($requestStack)->failPaymentRequest( + $paymentRequest, + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); + } + + public function testFailPaymentRequest_withNoRequestAtAll_stillFailsThePaymentRequest(): void + { + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->createApplier(new RequestStack())->failPaymentRequest( + $paymentRequest, + $this->createMock(PaymentInterface::class), + new \LogicException('boom'), + PaymentCaptureFlow::Alias, + ); + } + + public function testFailPaymentRequest_logsSetsResponseDataAndAppliesFailTransition(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->logger->expects(self::once())->method('error') + ->with(self::stringContains('Hosted payment creation failed.'), self::anything()); + $paymentRequest->expects(self::once())->method('setResponseData')->with(['error' => 'boom']); + $this->stateMachine->expects(self::once())->method('apply') + ->with($paymentRequest, PaymentRequestTransitions::GRAPH, PaymentRequestTransitions::TRANSITION_FAIL); + + $this->applier->failPaymentRequest($paymentRequest, $payment, new \LogicException('boom'), PaymentCaptureFlow::Hosted); + } + + public function testApplyOutcome_withRedirectHtml_storesItAndNeverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0001"}', null, '
3ds
', null); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_html' => '
3ds
']); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withRedirectUrl_storesItAndNeverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1"}', 'https://example.com/3ds', null, null); + + $paymentRequest->expects(self::once())->method('setResponseData')->with(['redirect_url' => 'https://example.com/3ds']); + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withDirectSuccessExecCode_appliesPaidOutcomeToOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::PAID); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testApplyOutcome_withDirectSuccessOnADeferredCapturePayment_appliesAuthorizedNotPaid(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + $payment->method('getDetails')->willReturn([AuthorizationDetails::DEFERRED => true]); + $output = new PaymentOutput(201, '{"id":"pay_1","execCode":"0000"}', null, null, null); + + $this->orderStateMutator->expects(self::once())->method('apply')->with('42', PaymentOutcome::AUTHORIZED); + + $this->applier->applyOutcome($this->createMock(PaymentRequestInterface::class), $payment, $output); + } + + public function testApplyOutcome_withNoExecCodeInResponseBody_neverAppliesOrderStateMutator(): void + { + $payment = $this->createMock(PaymentInterface::class); + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $output = new PaymentOutput(201, '{"id":"pay_1"}', null, null, null); + + $this->orderStateMutator->expects(self::never())->method('apply'); + + $this->applier->applyOutcome($paymentRequest, $payment, $output); + } + + public function testWarnWhenPaymentIdMissing_withNoPaymentId_logsAWarningWithIdsOnly(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $this->logger->expects(self::once())->method('warning')->with( + '[PayPlug][UPC] Payment created but the response carried no payment id; it cannot be refunded from the back office until PayPlug returns a payment id.', + self::callback(static fn (array $context): bool => [ + 'sylius_payment_id' => 42, + 'flow' => 'Hosted', + 'hosted_fields_operation_id' => 'op_1', + ] === $context), + ); + + $this->applier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Hosted, ['hosted_fields_operation_id' => 'op_1']); + } + + public function testWarnWhenPaymentIdMissing_withNeitherId_logsANullOperationId(): void + { + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getId')->willReturn(42); + + $this->logger->expects(self::once())->method('warning')->with( + self::anything(), + self::callback(static fn (array $context): bool => 'Alias' === $context['flow'] && + \array_key_exists('hosted_fields_operation_id', $context) && + null === $context['hosted_fields_operation_id']), + ); + + $this->applier->warnWhenPaymentIdMissing($payment, PaymentCaptureFlow::Alias, []); + } + + public function testWarnWhenPaymentIdMissing_withAPaymentId_logsNothing(): void + { + $this->logger->expects(self::never())->method('warning'); + + $this->applier->warnWhenPaymentIdMissing( + $this->createMock(PaymentInterface::class), + PaymentCaptureFlow::Alias, + ['hosted_fields_payment_id' => 'pay_1', 'hosted_fields_operation_id' => 'op_1'], + ); + } +} diff --git a/tests/PHPUnit/Upc/PayplugCardPersisterTest.php b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php new file mode 100644 index 00000000..7be544ce --- /dev/null +++ b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php @@ -0,0 +1,235 @@ +payplugCardFactory = $this->createMock(FactoryInterface::class); + $this->payplugCardRepository = $this->createMock(RepositoryInterface::class); + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + + $this->persister = new PayplugCardPersister($this->payplugCardFactory, $this->payplugCardRepository, $this->managerRegistry); + } + + private function paymentWithOrder(?CustomerInterface $customer): PaymentInterface&MockObject + { + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getOrder')->willReturn($order); + + return $payment; + } + + private function corePaymentMethod(bool $live = false): CorePaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn(['live' => $live]); + + $method = $this->createMock(CorePaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } + + public function testPersist_withNoCustomerOnTheOrder_doesNotPersistACard(): void + { + $payment = $this->paymentWithOrder(null); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + } + + public function testPersist_withMethodNotACorePaymentMethod_doesNotPersistACard(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + $method = $this->createMock(PaymentMethodInterface::class); + + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $method, [], []); + } + + public function testPersist_withAliasAlreadyStored_doesNotPersistADuplicate(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $this->payplugCardRepository->method('findOneBy') + ->with(['externalId' => 'alias_1', 'isLive' => false]) + ->willReturn(new Card()); + $this->payplugCardRepository->expects(self::never())->method('add'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + } + + public function testPersist_whenAddLosesARaceAgainstAConcurrentPersistCallForTheSameAlias_doesNotThrow(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->method('add')->with($card) + ->willThrowException($this->createMock(UniqueConstraintViolationException::class)); + $this->managerRegistry->expects(self::once())->method('resetManager'); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + + $this->addToAssertionCount(1); + } + + public function testPersist_withFetchedCardDataAvailable_takesPrecedenceOverDetailsFallback(): void + { + $customer = $this->createMock(CustomerInterface::class); + $payment = $this->paymentWithOrder($customer); + $method = $this->corePaymentMethod(live: true); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + $this->payplugCardRepository->expects(self::once())->method('add')->with($card); + + $this->persister->persist( + 'alias_1', + $payment, + $method, + [ + 'hosted_fields_selected_brand' => 'MASTERCARD', + 'hosted_fields_last4' => '0000', + 'hosted_fields_expiration_month' => 1, + 'hosted_fields_expiration_year' => 2020, + 'hosted_fields_country' => 'DE', + ], + [ + 'brand' => 'VISA', + 'last4' => '4242', + 'expirationMonth' => 12, + 'expirationYear' => 2030, + ], + ); + + self::assertSame($customer, $card->getCustomer()); + self::assertSame('alias_1', $card->getExternalId()); + self::assertSame('VISA', $card->getBrand()); + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame(2030, $card->getExpirationYear()); + // No card country field exists on the operation resource, so it always comes from $details. + self::assertSame('DE', $card->getCountryCode()); + self::assertTrue($card->isLive()); + self::assertSame($method, $card->getPaymentMethod()); + } + + public function testPersist_withNoFetchedCardDataAndNoDetails_usesEmptyDefaults(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), [], []); + + self::assertSame('', $card->getBrand()); + self::assertSame('', $card->getLast4()); + self::assertSame(0, $card->getExpirationMonth()); + self::assertSame(0, $card->getExpirationYear()); + self::assertSame('', $card->getCountryCode()); + } + + public function testPersist_withValidDetailsFallbackOnly_usesTheSanitizedValues(): void + { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + // Computed relative to today rather than hardcoded, since sanitizeExpirationYear() rejects + // anything before the wall-clock current year — a fixed literal would eventually become a + // past year and start failing this test for no code-regression reason. + $futureYear = (int) (new \DateTimeImmutable())->format('Y') + 1; + + $this->persister->persist( + 'alias_1', + $payment, + $this->corePaymentMethod(), + [ + 'hosted_fields_last4' => '4242', + 'hosted_fields_expiration_month' => 12, + 'hosted_fields_expiration_year' => $futureYear, + 'hosted_fields_country' => 'fr', + ], + [], + ); + + self::assertSame('4242', $card->getLast4()); + self::assertSame(12, $card->getExpirationMonth()); + self::assertSame($futureYear, $card->getExpirationYear()); + // Uppercased regardless of the case the client submitted it in. + self::assertSame('FR', $card->getCountryCode()); + } + + /** + * @dataProvider malformedDetailsFallbackProvider + * + * @param mixed[] $details + */ + public function testPersist_withMalformedDetailsFallbackValues_discardsThemAsIfAbsent( + array $details, + string $getter, + string|int $defaultValue, + ): void { + $payment = $this->paymentWithOrder($this->createMock(CustomerInterface::class)); + + $card = new Card(); + $this->payplugCardFactory->method('createNew')->willReturn($card); + + $this->persister->persist('alias_1', $payment, $this->corePaymentMethod(), $details, []); + + self::assertSame($defaultValue, $card->$getter()); + } + + /** @return array */ + public static function malformedDetailsFallbackProvider(): array + { + return [ + 'brand not in the allowed list' => [['hosted_fields_selected_brand' => 'AMEX'], 'getBrand', ''], + 'last4 not 4 digits' => [['hosted_fields_last4' => '42'], 'getLast4', ''], + 'last4 not numeric' => [['hosted_fields_last4' => 'abcd'], 'getLast4', ''], + 'last4 with trailing newline' => [['hosted_fields_last4' => "4242\n"], 'getLast4', ''], + 'expiration month out of range' => [['hosted_fields_expiration_month' => 13], 'getExpirationMonth', 0], + 'expiration month zero' => [['hosted_fields_expiration_month' => 0], 'getExpirationMonth', 0], + 'expiration year in the past' => [['hosted_fields_expiration_year' => 2000], 'getExpirationYear', 0], + 'expiration year implausibly far ahead' => [['hosted_fields_expiration_year' => 9999], 'getExpirationYear', 0], + 'country not two letters' => [['hosted_fields_country' => 'FRA'], 'getCountryCode', ''], + 'country not alphabetic' => [['hosted_fields_country' => '12'], 'getCountryCode', ''], + 'country with trailing newline' => [['hosted_fields_country' => "fr\n"], 'getCountryCode', ''], + ]; + } +} diff --git a/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php new file mode 100644 index 00000000..f3c5f4ae --- /dev/null +++ b/tests/PHPUnit/Upc/RefundDetailsLockKeyTest.php @@ -0,0 +1,38 @@ +expectException(\LogicException::class); + + RefundDetailsLockKey::forPaymentId([42]); + } + + /** + * The same payment id must always resolve to the same key regardless of caller — this is what + * lets RefundPaymentProcessor and HostedFieldsWebhookNotificationHandler actually serialize + * against each other. + */ + public function testForPaymentId_isStableAcrossCalls(): void + { + self::assertSame(RefundDetailsLockKey::forPaymentId(42), RefundDetailsLockKey::forPaymentId(42)); + } +} diff --git a/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php b/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php new file mode 100644 index 00000000..7fdf0ebc --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusOrderStateMutatorTest.php @@ -0,0 +1,88 @@ +paymentRepository = $this->createMock(PaymentRepositoryInterface::class); + $this->stateMachine = $this->createMock(StateMachineInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->mutator = new SyliusOrderStateMutator($this->paymentRepository, $this->stateMachine, $this->logger); + } + + /** + * @dataProvider outcomeToTransitionProvider + */ + public function testApply_mapsOutcomeToTheExpectedTransition(string $outcome, string $expectedTransition): void + { + $payment = $this->createMock(PaymentInterface::class); + $this->paymentRepository->method('find')->with(42)->willReturn($payment); + $this->stateMachine->method('can')->with($payment, PaymentTransitions::GRAPH, $expectedTransition)->willReturn(true); + $this->stateMachine->expects(self::once())->method('apply')->with($payment, PaymentTransitions::GRAPH, $expectedTransition); + + $this->mutator->apply('42', $outcome); + } + + /** + * @return iterable + */ + public static function outcomeToTransitionProvider(): iterable + { + yield 'paid' => [PaymentOutcome::PAID, PaymentTransitions::TRANSITION_COMPLETE]; + yield 'capture_required' => [PaymentOutcome::CAPTURE_REQUIRED, PaymentTransitions::TRANSITION_COMPLETE]; + yield 'authorized' => [PaymentOutcome::AUTHORIZED, PaymentTransitions::TRANSITION_AUTHORIZE]; + yield 'refunded' => [PaymentOutcome::REFUNDED, PaymentTransitions::TRANSITION_REFUND]; + yield 'failed' => [PaymentOutcome::FAILED, PaymentTransitions::TRANSITION_FAIL]; + } + + public function testApply_forThreeDsPending_doesNothing(): void + { + $payment = $this->createMock(PaymentInterface::class); + $this->paymentRepository->method('find')->willReturn($payment); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->mutator->apply('42', PaymentOutcome::THREE_DS_PENDING); + } + + public function testApply_whenPaymentNotFound_logsAndDoesNothing(): void + { + $this->paymentRepository->method('find')->with(42)->willReturn(null); + $this->logger->expects(self::once())->method('error'); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->mutator->apply('42', PaymentOutcome::PAID); + } + + public function testApply_whenTransitionNotAllowed_logsAndDoesNotApply(): void + { + $payment = $this->createMock(PaymentInterface::class); + $this->paymentRepository->method('find')->willReturn($payment); + $this->stateMachine->method('can')->willReturn(false); + $this->logger->expects(self::once())->method('warning'); + $this->stateMachine->expects(self::never())->method('apply'); + + $this->mutator->apply('42', PaymentOutcome::PAID); + } +} diff --git a/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php b/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php new file mode 100644 index 00000000..af3c17bd --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusPaymentOperationRepositoryTest.php @@ -0,0 +1,110 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->doctrineRepository = $this->createMock(EntityRepository::class); + $this->entityManager->method('getRepository')->with(PayPlugOperation::class)->willReturn($this->doctrineRepository); + $this->repository = new SyliusPaymentOperationRepository($this->entityManager); + } + + public function testGetByOrderId_whenFound_returnsOperationData(): void + { + $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + $this->doctrineRepository->method('findOneBy')->with(['orderId' => '42'])->willReturn($entity); + + $result = $this->repository->getByOrderId('42'); + + self::assertSame('op_123', $result->operationId); + } + + public function testGetByOrderId_whenMissing_throwsPaymentNotFoundException(): void + { + $this->doctrineRepository->method('findOneBy')->with(['orderId' => '42'])->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->repository->getByOrderId('42'); + } + + public function testGetByOperationId_whenFound_returnsOperationData(): void + { + $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity); + + $result = $this->repository->getByOperationId('op_123'); + + self::assertSame('42', $result->orderId); + } + + public function testGetByOperationId_whenMissing_throwsPaymentNotFoundException(): void + { + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null); + + $this->expectException(PaymentNotFoundException::class); + + $this->repository->getByOperationId('op_123'); + } + + public function testSave_persistsAndFlushesANewEntity(): void + { + $data = new OperationData('op_123', '0000', PaymentOutcome::PAID, 1000, '42'); + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null); + + $this->entityManager->expects(self::once())->method('persist') + ->with(self::isInstanceOf(PayPlugOperation::class)); + $this->entityManager->expects(self::once())->method('flush'); + + $this->repository->save($data); + } + + public function testIsTreated_delegatesToTheStoredEntity(): void + { + $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + $entity->markTreated(); + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity); + + self::assertTrue($this->repository->isTreated('op_123')); + } + + public function testIsTreated_whenMissing_returnsFalse(): void + { + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn(null); + + self::assertFalse($this->repository->isTreated('op_123')); + } + + public function testMarkTreated_flagsTheStoredEntityAndFlushes(): void + { + $entity = new PayPlugOperation('42', 'op_123', '0000', PaymentOutcome::PAID, 1000); + $this->doctrineRepository->method('findOneBy')->with(['operationId' => 'op_123'])->willReturn($entity); + + $this->entityManager->expects(self::once())->method('flush'); + + $this->repository->markTreated('op_123'); + + self::assertTrue($entity->isTreated()); + } +} diff --git a/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php new file mode 100644 index 00000000..d26d6393 --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusUnifiedApiHttpClientTest.php @@ -0,0 +1,123 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->adapter = new SyliusUnifiedApiHttpClient($this->httpClient, $this->logger, true); + } + + public function testGet_sendsGetRequestWithHeaders(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}'); + + $this->httpClient->expects(self::once())->method('request') + ->with('GET', 'https://api.payplug.com/payments/pay_123', [ + 'headers' => ['Authorization' => 'Bearer jwt'], + 'timeout' => 10, + ]) + ->willReturn($response); + + $result = $this->adapter->get('https://api.payplug.com/payments/pay_123', ['Authorization' => 'Bearer jwt']); + + self::assertSame(['status' => 200, 'body' => '{"id":"pay_123"}'], $result); + } + + public function testPostJson_sendsJsonEncodedBody(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(201); + $response->method('getContent')->with(false)->willReturn('{"id":"pay_123"}'); + + $this->httpClient->expects(self::once())->method('request') + ->with('POST', 'https://api.payplug.com/payments', [ + 'json' => ['amount' => 1000], + 'headers' => ['Authorization' => 'Bearer jwt'], + 'timeout' => 10, + ]) + ->willReturn($response); + + $result = $this->adapter->postJson('https://api.payplug.com/payments', ['amount' => 1000], ['Authorization' => 'Bearer jwt']); + + self::assertSame(['status' => 201, 'body' => '{"id":"pay_123"}'], $result); + } + + public function testGet_onTransportFailure_returnsZeroStatusInsteadOfThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willThrowException(new TransportException('Could not resolve host')); + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->get('https://api.payplug.com/payments/pay_123'); + + self::assertSame(0, $result['status']); + self::assertSame('Could not resolve host', $result['body']); + } + + public function testPostJson_onTransportFailure_returnsZeroStatusInsteadOfThrowing(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willThrowException(new TransportException('Could not resolve host')); + $this->httpClient->method('request')->willReturn($response); + + $result = $this->adapter->postJson('https://api.payplug.com/payments', []); + + self::assertSame(0, $result['status']); + self::assertSame('Could not resolve host', $result['body']); + } + + public function testGet_whenVerifyTlsDisabled_passesVerifyPeerAndVerifyHostFalse(): void + { + $adapter = new SyliusUnifiedApiHttpClient($this->httpClient, $this->logger, false); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->with(false)->willReturn('{}'); + + $this->httpClient->expects(self::once())->method('request') + ->with('GET', 'https://staging-internal-payment.gcp.dlns.io/processing-operations/operations/op_1', [ + 'headers' => [], + 'verify_peer' => false, + 'verify_host' => false, + 'timeout' => 10, + ]) + ->willReturn($response); + + $adapter->get('https://staging-internal-payment.gcp.dlns.io/processing-operations/operations/op_1'); + } + + public function testGet_whenVerifyTlsEnabled_neverPassesVerifyPeerOrVerifyHost(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->with(false)->willReturn('{}'); + + $this->httpClient->expects(self::once())->method('request') + ->with('GET', 'https://api.payplug.com/payments/pay_123', ['headers' => [], 'timeout' => 10]) + ->willReturn($response); + + $this->adapter->get('https://api.payplug.com/payments/pay_123'); + } +} diff --git a/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php new file mode 100644 index 00000000..578738fe --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php @@ -0,0 +1,194 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->configurationRepository = new SyliusUpcConfigurationRepository($this->entityManager); + } + + private function gatewayConfig(array $config): GatewayConfigInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + + return $gatewayConfig; + } + + /** A repository scoped to a config carrying $config — the only supported way to read anything. */ + private function scopedWith(array $config): ScopedConfigurationRepositoryInterface + { + return $this->configurationRepository->withGatewayConfig($this->gatewayConfig($config)); + } + + // ------------------------------------------------------------------------- + // Explicit scoping — withGatewayConfig() + // ------------------------------------------------------------------------- + + /** + * Since PRE-3628 several enabled CB gateway configs may exist, one per channel. The scoped + * repository reads the config it was handed; the factory-name lookup that used to resolve an + * arbitrary channel's account is gone, along with the gateway config repository it needed. + */ + public function testWithGatewayConfig_readsCredentialsFromTheScopedConfig(): void + { + $scoped = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => false, + 'test_client' => ['client_id' => 'de_id', 'client_secret' => 'de_secret'], + ])); + + self::assertSame('de_id', $scoped->getClientId()); + self::assertSame('de_secret', $scoped->getClientSecret()); + } + + /** + * The whole point of the ticket: two enabled CB configs on different channels, each resolving + * its own credentials. Also pins the wither as immutable — scoping for DE must not disturb the + * instance already scoped to FR, which is what makes the shared service safe to reuse. + */ + public function testWithGatewayConfig_withTwoChannelsSharingTheCbFactory_keepsEachScopeIndependent(): void + { + $fr = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => true, + 'live_client' => ['client_id' => 'fr_id', 'client_secret' => 'fr_secret'], + ])); + $de = $this->configurationRepository->withGatewayConfig($this->gatewayConfig([ + 'live' => true, + 'live_client' => ['client_id' => 'de_id', 'client_secret' => 'de_secret'], + ])); + + self::assertSame('fr_id', $fr->getClientId()); + self::assertSame('de_id', $de->getClientId()); + self::assertNotSame($fr, $de); + } + + /** + * Every consumer holds a PaymentMethodInterface rather than a bare gateway config, so this is + * the form they actually use; it exists to keep the null-check in one place instead of five. + */ + public function testForPaymentMethod_scopesToThatMethodsOwnGatewayConfig(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($this->gatewayConfig([ + 'live' => false, + 'test_client' => ['client_id' => 'fr_id', 'client_secret' => 'fr_secret'], + ])); + + self::assertSame('fr_id', $this->configurationRepository->forPaymentMethod($method)->getClientId()); + } + + public function testForPaymentMethod_whenTheMethodHasNoGatewayConfig_throws(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn(null); + + $this->expectException(\LogicException::class); + + $this->configurationRepository->forPaymentMethod($method); + } + + /** + * A loud failure at an unscoped call site beats the previous silent wrong-account behaviour: + * any path that forgot to scope reports itself at runtime instead of signing with whichever + * config Doctrine returned first. + */ + public function testGetClientId_whenUnscoped_throwsInsteadOfResolvingAnArbitraryConfig(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not been scoped'); + + $this->configurationRepository->getClientId(); + } + + public function testGet_whenUnscoped_throwsInsteadOfResolvingAnArbitraryConfig(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not been scoped'); + + $this->configurationRepository->get('payplug_webhook_authorization_header'); + } + + public function testGetClientId_whenLive_readsFromLiveClient(): void + { + $scoped = $this->scopedWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + + self::assertSame('live_id', $scoped->getClientId()); + } + + public function testGetClientId_whenNotLive_readsFromTestClient(): void + { + $scoped = $this->scopedWith(['live' => false, 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret']]); + + self::assertSame('test_id', $scoped->getClientId()); + } + + public function testGetClientSecret_whenLive_readsFromLiveClient(): void + { + $scoped = $this->scopedWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + + self::assertSame('live_secret', $scoped->getClientSecret()); + } + + public function testGetClientId_whenNoClientConfigStored_returnsEmptyString(): void + { + $scoped = $this->scopedWith(['live' => false]); + + self::assertSame('', $scoped->getClientId()); + } + + public function testGetPublicKeyId_readsHfIdentifier(): void + { + $scoped = $this->scopedWith([PayPlugGatewayFactory::HF_IDENTIFIER => 'hf_ident_123']); + + self::assertSame('hf_ident_123', $scoped->getPublicKeyId()); + } + + public function testGetPublicKeyValue_returnsEmptyString(): void + { + $scoped = $this->scopedWith([]); + + self::assertSame('', $scoped->getPublicKeyValue()); + } + + public function testGet_readsArbitraryKeyFromConfig(): void + { + $scoped = $this->scopedWith(['payplug_webhook_authorization_header' => 'Bearer shared-secret']); + + self::assertSame('Bearer shared-secret', $scoped->get('payplug_webhook_authorization_header')); + } + + public function testGet_whenKeyMissing_returnsNull(): void + { + $scoped = $this->scopedWith([]); + + self::assertNull($scoped->get('missing_key')); + } + + public function testSet_mergesTheKeyIntoConfigAndFlushesTheScopedConfig(): void + { + $gatewayConfig = $this->gatewayConfig(['existing' => 'value']); + $gatewayConfig->expects(self::once())->method('setConfig') + ->with(['existing' => 'value', 'new_key' => 'new_value']); + $this->entityManager->expects(self::once())->method('flush'); + + $this->configurationRepository->withGatewayConfig($gatewayConfig)->set('new_key', 'new_value'); + } +} diff --git a/tests/PHPUnit/Upc/SyliusUpcLockTest.php b/tests/PHPUnit/Upc/SyliusUpcLockTest.php new file mode 100644 index 00000000..51e52eec --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusUpcLockTest.php @@ -0,0 +1,60 @@ +lockFactory = $this->createMock(LockFactory::class); + $this->lock = new SyliusUpcLock($this->lockFactory); + } + + public function testAcquire_whenLockIsFree_returnsTrue(): void + { + $lockInterface = $this->createMock(SharedLockInterface::class); + $lockInterface->method('acquire')->with(false)->willReturn(true); + $this->lockFactory->method('createLock')->with('key', 30)->willReturn($lockInterface); + + self::assertTrue($this->lock->acquire('key', 30)); + } + + public function testAcquire_whenLockIsHeld_returnsFalse(): void + { + $lockInterface = $this->createMock(SharedLockInterface::class); + $lockInterface->method('acquire')->with(false)->willReturn(false); + $this->lockFactory->method('createLock')->willReturn($lockInterface); + + self::assertFalse($this->lock->acquire('key', 30)); + } + + public function testRelease_releasesAPreviouslyAcquiredLock(): void + { + $lockInterface = $this->createMock(SharedLockInterface::class); + $lockInterface->method('acquire')->willReturn(true); + $lockInterface->expects(self::once())->method('release'); + $this->lockFactory->method('createLock')->willReturn($lockInterface); + + $this->lock->acquire('key', 30); + $this->lock->release('key'); + } + + public function testRelease_whenNothingWasAcquired_doesNothing(): void + { + $this->expectNotToPerformAssertions(); + + $this->lock->release('never-acquired'); + } +} diff --git a/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php b/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php new file mode 100644 index 00000000..ceb653b6 --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusUpcLoggerTest.php @@ -0,0 +1,44 @@ +psrLogger = $this->createMock(LoggerInterface::class); + $this->logger = new SyliusUpcLogger($this->psrLogger); + } + + public function testDebug_delegatesToThePsrLogger(): void + { + $this->psrLogger->expects(self::once())->method('debug')->with('a message', ['key' => 'value']); + + $this->logger->debug('a message', ['key' => 'value']); + } + + public function testInfo_delegatesToThePsrLogger(): void + { + $this->psrLogger->expects(self::once())->method('info')->with('a message', []); + + $this->logger->info('a message'); + } + + public function testError_delegatesToThePsrLogger(): void + { + $this->psrLogger->expects(self::once())->method('error')->with('a message', ['key' => 'value']); + + $this->logger->error('a message', ['key' => 'value']); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php b/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php new file mode 100644 index 00000000..bce512d0 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiAuthorizationOperatorTest.php @@ -0,0 +1,155 @@ + 'Bearer cached-jwt', 'Content-Type' => 'application/json']; + + private IUnifiedApiHttpClient&MockObject $unifiedApiHttpClient; + + private ScopedConfigurationRepositoryInterface&MockObject $configurationRepository; + + private UnifiedApiAuthorizationOperator $operator; + + protected function setUp(): void + { + $this->unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $tokenCache = $this->createMock(ITokenCache::class); + $tokenCache->method('get')->willReturn('cached-jwt'); + + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn('client_abc'); + $scoped->method('getClientSecret')->willReturn('secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod')->willReturn($scoped); + + $oauth2Client = new OAuth2Client($this->createMock(IOAuthHttpClient::class), 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + + $this->operator = new UnifiedApiAuthorizationOperator( + $this->unifiedApiHttpClient, + new TokenManager($tokenCache, $oauth2Client), + $this->configurationRepository, + 'https://api.payplug.com', + ); + } + + public function testCapture_partial_postsTheAmountToTheCaptureEndpointOfTheMethodsOwnAccount(): void + { + $method = $this->hostedFieldsPaymentMethod('acct_123'); + $this->configurationRepository->expects(self::once())->method('forPaymentMethod')->with($method); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/capture', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Capture #1 for order order_1', + 'amount' => 300, + 'currency' => 'USD', + ], + self::AUTH_HEADERS, + ) + ->willReturn(['status' => 200, 'body' => '{"amount":300,"requestedAmount":1000,"operationIds":["op_c1"]}']); + + $output = $this->operator->capture($method, 'pay_123', 'order_1', 300, 'USD'); + + self::assertSame(700, $output->remainingCapturableAmount); + } + + public function testCancel_full_postsAVoidWithoutAmount(): void + { + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/void', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Cancellation #1 for order order_1', + ], + self::AUTH_HEADERS, + ) + ->willReturn(['status' => 200, 'body' => '{"operationIds":["op_v1"]}']); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', null); + } + + public function testCancel_partialRefusedByTheContract_surfacesUpcsOwnException(): void + { + // The real staging answer (2026-09-24) to a partial void on an account without the option. + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 403, + 'body' => '{"status":403,"error":"Forbidden","errorCategory":"INVALID_REQUEST","message":"The operation is not allowed."}', + ]); + + $this->expectException(PartialCancellationNotAllowedException::class); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 400, 'USD', 1); + } + + /** + * Staging, 2026-09-24: a second capture on the same authorization comes back as HTTP 200 with + * a failure execCode. UPC must not let that through as a successful CaptureOutput. + */ + public function testCapture_secondCaptureRefusedWithA200_surfacesMultipleCaptureNotAllowed(): void + { + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 200, + 'body' => '{"descriptor":"Plug","execCode":"4011","message":"Duplicate request.","operationIds":["op_dup"]}', + ]); + + $this->expectException(MultipleCaptureNotAllowedException::class); + + $this->operator->capture($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 6201, 'USD', 2); + } + + public function testCancel_partial_sendsTheCurrencyUpcRequiresAlongsideAnAmount(): void + { + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with(self::anything(), self::callback( + static fn (array $body): bool => 1000 === $body['amount'] && 'USD' === $body['currency'], + )) + ->willReturn(['status' => 200, 'body' => '{"execCode":"0000","operationIds":["op_v1"]}']); + + $this->operator->cancel($this->hostedFieldsPaymentMethod('acct_123'), 'pay_123', 'order_1', 1000, 'USD', 1); + } + + private function hostedFieldsPaymentMethod(string $accountId): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => $accountId, + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiContractTest.php b/tests/PHPUnit/Upc/UnifiedApiContractTest.php new file mode 100644 index 00000000..43655e14 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiContractTest.php @@ -0,0 +1,45 @@ + ['fullName' => 'John Doe'], 'saveFutureUsage' => true], + ); + + $body = $dto->createPayloadBody(); + + self::assertSame('hf_token_abc', $body['paymentMethod']['hfToken'] ?? null); + self::assertSame('John Doe', $body['paymentMethod']['details']['fullName'] ?? null); + self::assertTrue($body['paymentMethod']['saveFutureUsage'] ?? false); + } + + public function testPaymentDto_sendsTheAliasInPaymentMethodStoredId(): void + { + $dto = new PaymentDto( + new CommonFieldsDto('acct_123', 1000, 'EUR', '000000042'), + 'card_alias_1', + 'ONE_CLICK', + ); + + $body = $dto->createPayloadBody(); + + self::assertSame('card_alias_1', $body['paymentMethod']['storedId'] ?? null); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php new file mode 100644 index 00000000..a43a66c3 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php @@ -0,0 +1,147 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->fetcher = new UnifiedApiOperationStatusFetcher( + $this->unifiedApiHttpClient, + $tokenManager, + $this->configurationRepository, + 'https://api.payplug.com', + ); + } + + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * An operation id alone does not identify an account. Both call sites poll for a specific + * payment, so they pass that payment's method and the fetch is authenticated against the + * account the operation actually lives on. + */ + public function testGetOperation_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 200, 'body' => '{}']); + + $this->fetcher->getOperation('op_1', $method); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); + } + + public function testGetOperation_withValidCredentials_returnsTheRawResponse(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $body = '{"id":"op_1","execCode":"0000","orderId":"000000072","amount":7400}'; + $this->unifiedApiHttpClient->method('get') + ->with('https://api.payplug.com/processing-operations/operations/public/op_1', ['Authorization' => 'Bearer cached-jwt']) + ->willReturn(['status' => 200, 'body' => $body]); + + $response = $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); + + self::assertSame(['status' => 200, 'body' => $body], $response); + } + + /** + * Unlike the old (deleted) UnifiedApiOperationService, an unknown operation id is not given + * its own exception type here — getOperation() folds a 404 into the same generic ApiException + * as any other non-2xx status, since no caller currently needs to tell them apart. + */ + public function testGetOperation_onMissingOperation_throwsApiException(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 404, 'body' => '{}']); + + $this->expectException(ApiException::class); + $this->expectExceptionCode(404); + + $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); + } + + public function testGetOperation_onNon2xxResponse_throwsApiException(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('get')->willReturn(['status' => 500, 'body' => '{}']); + + $this->expectException(ApiException::class); + + $this->fetcher->getOperation('op_1', $this->createMock(PaymentMethodInterface::class)); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php new file mode 100644 index 00000000..97570ebd --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php @@ -0,0 +1,155 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->creator = new UnifiedApiPaymentCreator( + $this->unifiedApiHttpClient, + $tokenManager, + $this->configurationRepository, + 'https://api.payplug.com', + ); + } + + private function dto(): HostedFieldDto + { + return new HostedFieldDto(new CommonFieldsDto('acct_123', 1000, 'eur', '42'), 'hf_token_abc'); + } + + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * The payload DTO carries no account context, so the payment method is what tells the creator + * which PayPlug account to sign with. Since PRE-3628 two CB payment methods on different + * channels can hold different credentials, so reading them off an unscoped repository would + * create the payment on whichever account Doctrine happened to return. + */ + public function testCreatePayment_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->createMock(PaymentMethodInterface::class); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $this->tokenCache->method('get')->willReturn(null); // force a token request + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']); + + $this->creator->createPayment($this->dto(), $method); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); + } + + public function testCreateHostedPayment_withValidCredentials_returnsTheOutput(): void + { + $this->tokenCache->method('get')->willReturn(null); + $this->oauthHttpClient->method('post')->willReturn([ + 'status' => 200, + 'body' => json_encode(['access_token' => 'fresh-jwt', 'expires_in' => 300, 'token_type' => 'Bearer']), + ]); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 201, 'body' => '{"id":"pay_1"}']); + + $output = $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); + + self::assertSame(201, $output->status); + self::assertNull($output->redirectUrl); + } + + public function testCreateHostedPayment_withPending3ds_extractsTheRedirectUrl(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('postJson')->willReturn([ + 'status' => 200, + 'body' => json_encode(['id' => 'pay_1', 'redirect' => ['url' => 'https://3ds.payplug.com/challenge']]), + ]); + + $output = $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); + + self::assertSame('https://3ds.payplug.com/challenge', $output->redirectUrl); + } + + public function testCreateHostedPayment_onNon2xxResponse_throwsApiException(): void + { + $this->tokenCache->method('get')->willReturn('cached-jwt'); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']); + + $this->expectException(ApiException::class); + + $this->creator->createPayment($this->dto(), $this->createMock(PaymentMethodInterface::class)); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php new file mode 100644 index 00000000..5019db3d --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php @@ -0,0 +1,251 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + + $this->scopedConfiguration = $this->scopedConfigurationWith('client_abc', 'secret_xyz'); + $this->configurationRepository = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $this->configurationRepository->method('forPaymentMethod') + ->willReturnCallback(fn (): ScopedConfigurationRepositoryInterface => $this->scopedConfiguration); + + $oauth2Client = new OAuth2Client($this->oauthHttpClient, 'https://api.payplug.com', '', '', 'https://www.payplug.com'); + $tokenManager = new TokenManager($this->tokenCache, $oauth2Client); + + $this->creator = new UnifiedApiRefundCreator( + $this->unifiedApiHttpClient, + $tokenManager, + $this->configurationRepository, + 'https://api.payplug.com', + ); + + // Swappable so a test that needs to observe the token request itself can force a cache miss. + $this->tokenCache->method('get')->willReturnCallback(fn (): ?string => $this->cachedToken); + } + + private function scopedConfigurationWith( + string $clientId, + string $clientSecret, + ): ScopedConfigurationRepositoryInterface&MockObject + { + $scoped = $this->createMock(ScopedConfigurationRepositoryInterface::class); + $scoped->method('getClientId')->willReturn($clientId); + $scoped->method('getClientSecret')->willReturn($clientSecret); + + return $scoped; + } + + /** + * createRefund() already routes the *account id* per payment method; the OAuth credentials it + * signs with have to follow the same method, or a refund on channel B is authenticated as + * channel A and rejected — or worse, accepted against the wrong merchant. + */ + public function testCreateRefund_signsWithTheCredentialsOfThePaymentMethodsOwnAccount(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_de'); + $this->scopedConfiguration = $this->scopedConfigurationWith('de_id', 'de_secret'); + $this->cachedToken = null; // force a real token request, so its credentials are observable + + $scopedFor = null; + $this->configurationRepository->expects(self::once()) + ->method('forPaymentMethod') + ->willReturnCallback(function (PaymentMethodInterface $m) use (&$scopedFor): ScopedConfigurationRepositoryInterface { + $scopedFor = $m; + + return $this->scopedConfiguration; + }); + + $sentCredentials = null; + $this->oauthHttpClient->method('post')->willReturnCallback( + function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array { + $sentCredentials = $headers['Authorization']; + + return ['status' => 200, 'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer'])]; + }, + ); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 200, 'body' => '{}']); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + + self::assertSame($method, $scopedFor); + self::assertSame('Basic ' . base64_encode('de_id:de_secret'), $sentCredentials); + } + + public function testCreateRefund_withoutAmount_sendsAFullRefundUsingTheMethodsOwnAccountId(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{"execCode":"0000"}']); + + $result = $this->creator->createRefund($method, 'pay_123', 'order_1'); + + self::assertSame(['status' => 200, 'body' => '{"execCode":"0000"}'], $result); + } + + public function testCreateRefund_withAmount_sendsAPartialRefund(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + 'amount' => 500, + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{}']); + + $this->creator->createRefund($method, 'pay_123', 'order_1', 500); + } + + public function testCreateRefund_onA404Response_throwsPaymentNotFoundException(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 404, 'body' => '{}']); + + $this->expectException(PaymentNotFoundException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + public function testCreateRefund_onNon2xxResponse_throwsApiException(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + $this->unifiedApiHttpClient->method('postJson')->willReturn(['status' => 500, 'body' => '{}']); + + $this->expectException(ApiException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + public function testCreateRefund_withANonPositiveAmount_throwsRefundAmountExceptionBeforeAnyNetworkCall(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->expectException(RefundAmountException::class); + + $this->creator->createRefund($method, 'pay_123', 'order_1', 0); + } + + /** + * The refund body must state what $amount's minor units are: without it the Unified API infers + * the currency from the account, which silently means the wrong thing for a multi-currency + * merchant. + */ + public function testCreateRefund_withCurrency_sendsItAlongsideTheAmount(): void + { + $method = $this->buildHostedFieldsPaymentMethod('acct_123'); + + $this->unifiedApiHttpClient->expects(self::once()) + ->method('postJson') + ->with( + 'https://api.payplug.com/api/payment-gateway/payments/pay_123/refund', + [ + 'account' => ['id' => 'acct_123'], + 'orderId' => 'order_1', + 'description' => 'Refund for order order_1', + 'amount' => 6800, + 'currency' => 'USD', + ], + ['Authorization' => 'Bearer cached-jwt', 'Content-Type' => 'application/json'], + ) + ->willReturn(['status' => 200, 'body' => '{}']); + + $this->creator->createRefund($method, 'pay_123', 'order_1', 6800, 'USD'); + } + + /** + * Credentials must come from $method's own gateway config, not from whichever + * Hosted-Fields-configured payment method IConfigurationRepository's backing store happens to + * resolve first — otherwise a merchant with more than one such payment method could have a + * refund routed to the wrong account. + */ + public function testCreateRefund_withNoConfiguredAccountId_throwsLogicExceptionBeforeAnyNetworkCall(): void + { + $method = $this->buildHostedFieldsPaymentMethod(''); + + $this->unifiedApiHttpClient->expects(self::never())->method('postJson'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Hosted Fields account id is not configured for this payment method.'); + + $this->creator->createRefund($method, 'pay_123', 'order_1'); + } + + private function buildHostedFieldsPaymentMethod( + string $accountId, + ): PaymentMethodInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::HF_IDENTIFIER => $accountId, + ]); + + $method = $this->createMock(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + return $method; + } +} diff --git a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php index f8fb7b70..5a97dd24 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -8,6 +8,11 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\BancontactGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\HasNoGatewayChannelConflict; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsCanSavePaymentMethod; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\IsScalapayAmountRangeValid; +use PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints\PayplugPermission; use PayPlug\SyliusPayPlugPlugin\Validator\PaymentMethodValidator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -137,12 +142,13 @@ public function testProcess_withViolations_disablesMethodAndFlashesErrors(): voi } // ------------------------------------------------------------------------- - // process() — PayPlug factory, no special flags → only IsCanSavePaymentMethod constraint + // process() — PayPlug factory, no special flags → base constraints only // ------------------------------------------------------------------------- /** * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all false. - * Verifies only the base IsCanSavePaymentMethod constraint (1 total) is passed to the validator. + * Verifies only the always-present constraint (1 total) is passed to the validator: + * IsCanSavePaymentMethod. */ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOnly(): void { @@ -157,8 +163,8 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn ->expects(self::once()) ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { - // Only the base IsCanSavePaymentMethod constraint (no permission constraints) - self::assertCount(1, $constraints); + self::assertCount(2, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[1]); return new ConstraintViolationList(); }) @@ -173,12 +179,13 @@ public function testProcess_payplugFactory_noFlags_validatesWithBaseConstraintOn } // ------------------------------------------------------------------------- - // process() — PayPlug factory, all flags enabled → 4 constraints (base + 3 permissions) + // process() — PayPlug factory, all permission flags enabled → 4 constraints (1 base + 3 permissions) // ------------------------------------------------------------------------- /** * PayPlug gateway with ONE_CLICK, DEFERRED_CAPTURE and INTEGRATED_PAYMENT all true. - * Verifies 4 constraints are passed to the validator (base + one per enabled feature flag). + * Verifies 4 constraints are passed to the validator: the always-present + * IsCanSavePaymentMethod plus one per enabled feature flag. */ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConstraints(): void { @@ -194,7 +201,116 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst ->method('validate') ->willReturnCallback(function ($subject, array $constraints) { // Base + CAN_SAVE_CARD + CAN_CREATE_DEFERRED_PAYMENT + CAN_USE_INTEGRATED_PAYMENTS - self::assertCount(4, $constraints); + self::assertCount(5, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[4]); + + return new ConstraintViolationList(); + }) + ; + + $flashBag = $this->createMock(FlashBagInterface::class); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — PayPlug factory, hostedFields true, oneClick false → base constraint only + // ------------------------------------------------------------------------- + + /** + * PayPlug gateway in hosted_fields mode with oneClick absent/false. Verifies + * processPayplug() validates with the base IsCanSavePaymentMethod constraint only (1 total) — + * hosted_fields adds no permission constraint of its own, matching the redirected mode. + */ + public function testProcess_payplugFactory_hostedFieldsTrueOneClickFalse_validatesWithBaseConstraintOnly(): void + { + $config = [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::ONE_CLICK => false, + ]; + $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->willReturnCallback(function ($subject, array $constraints) { + self::assertCount(2, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[1]); + self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); + + return new ConstraintViolationList(); + }) + ; + + $flashBag = $this->createMock(FlashBagInterface::class); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — PayPlug factory, hostedFields true, oneClick true → base + CAN_SAVE_CARD + // ------------------------------------------------------------------------- + + /** + * PayPlug gateway in hosted_fields mode with oneClick=true. Verifies processPayplug() adds a + * PayplugPermission (CAN_SAVE_CARD) constraint alongside the base one (2 total) — the same + * behavior as oneClick in redirected/integrated_payment mode. + */ + public function testProcess_payplugFactory_hostedFieldsTrueOneClickTrue_validatesWithPermissionConstraint(): void + { + $config = [ + PayPlugGatewayFactory::HOSTED_FIELDS => true, + PayPlugGatewayFactory::ONE_CLICK => true, + ]; + $paymentMethod = $this->buildPaymentMethod(PayPlugGatewayFactory::FACTORY_NAME, $config); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->willReturnCallback(function ($subject, array $constraints) { + self::assertCount(3, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[2]); + self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); + self::assertInstanceOf(PayplugPermission::class, $constraints[1]); + + return new ConstraintViolationList(); + }) + ; + + $flashBag = $this->createMock(FlashBagInterface::class); + $session = $this->createMock(Session::class); + $session->method('getFlashBag')->willReturn($flashBag); + $this->requestStack->method('getSession')->willReturn($session); + + $this->paymentMethodValidator->process($paymentMethod); + } + + // ------------------------------------------------------------------------- + // process() — Scalapay factory → base constraint + amount range constraint + // ------------------------------------------------------------------------- + + /** + * Scalapay gateway config. Verifies both IsCanSavePaymentMethod and + * IsScalapayAmountRangeValid are passed to the validator (2 total). + */ + public function testProcess_scalapayFactory_validatesWithBaseAndAmountRangeConstraints(): void + { + $paymentMethod = $this->buildPaymentMethod(ScalapayGatewayFactory::FACTORY_NAME, []); + + $this->validator + ->expects(self::once()) + ->method('validate') + ->willReturnCallback(function ($subject, array $constraints) { + self::assertCount(3, $constraints); + self::assertInstanceOf(HasNoGatewayChannelConflict::class, $constraints[2]); + self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); + self::assertInstanceOf(IsScalapayAmountRangeValid::class, $constraints[1]); return new ConstraintViolationList(); }) diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 1faaa11a..b05df556 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -3,9 +3,36 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney is not (or is no longer) activated on your account. api_unknow_error: An error occurred. Please retry in few seconds. transaction_failed_1click: The transaction was not completed and your card was not charged. + transaction_failed: The transaction was not completed and your card was not charged. warning: payment_success_no_card_saved: The payment was successful but we were unable to save your credit card details. admin: oauth_callback_success: The OAuth connection was established successfully. payment_method_disabled: ⚠️ The payment method has been disabled because some validation criteria are not met. oauth_setup_error: An error occurred while setting up the OAuth connection. Please try again. + logout_success: The PayPlug account has been disconnected and this payment method has been disabled. + logout_error: An error occurred while disconnecting the PayPlug account. Please try again. + authorization: + capture_success: The capture has been performed. + cancel_success: The cancellation has been performed. + error: + not_authorized: This payment is not (or no longer) an authorization awaiting capture. Reload the page to see its current state. + stale: The order changed since this page was displayed (operation already performed?). Reload the page and check the remaining amount before trying again. + in_progress: Another operation is already in progress on this payment. Wait a few seconds, then reload the page. + expired: The authorization has expired and the funds have been released. No capture is possible any more; ask the customer for a new payment if needed. + cancel_after_capture: This payment has already been partly captured, so it can no longer be cancelled. Use a refund to return all or part of the captured amount. + nothing_remaining: Nothing is left to capture or cancel on this authorization. + invalid_amount: "Invalid amount. Enter a positive amount, e.g. 12.50, or leave the field blank for the remaining amount." + unsupported_currency: "An amount cannot be entered for a payment in this currency (%currency%). Leave the field blank to use the whole remaining amount. The payment was not changed." + amount_exceeds_remaining: "The amount exceeds what is still available (%remaining%). Enter a lower or equal amount." + amount_refused: PayPlug refused this amount (above the available amount, or invalid). Reload the page and check the remaining amount. + already_captured: PayPlug reports this payment as already captured. Reload the page; if the displayed state does not match, contact PayPlug support. + already_cancelled: PayPlug reports this payment as already cancelled. Reload the page; if the displayed state does not match, contact PayPlug support. + not_operable: PayPlug reports this payment is no longer in a state that allows this operation (already captured or cancelled). Reload the page; if the displayed state does not match, contact PayPlug support. + partial_cancellation_not_allowed: Partial cancellation is not enabled on your PayPlug contract. Cancel the whole amount (blank amount field) or contact PayPlug to enable the option. + conflict: Another operation is being processed by PayPlug for this payment. Try again in a moment. + refused_by_issuer: The customer's bank refused the operation. The payment was not changed. + multiple_capture_not_allowed: Only one capture is possible on this authorization with your PayPlug account, and it has already been performed. The remaining amount cannot be captured. For future orders, capture the final amount in one go, or contact PayPlug to enable multiple captures. + payment_not_found: This payment could not be found at PayPlug. Check that the payment method is connected to the right PayPlug account. + api_error: The operation could not be performed because of a PayPlug error. The payment was not changed; try again in a moment. + unexpected_error: The operation may have been performed by PayPlug, but it could not be recorded here. Check the payment in your PayPlug portal before trying again, to avoid capturing or cancelling twice. diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index b05b5110..fc9fa375 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -3,9 +3,36 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney n'est pas ou plus activé sur votre compte. api_unknow_error: Une erreur s'est produite. Veuillez réessayer dans quelques secondes. transaction_failed_1click: La transaction a échoué, votre carte de paiement ne sera pas débitée. + transaction_failed: La transaction a échoué, votre carte de paiement ne sera pas débitée. warning: payment_success_no_card_saved: Le paiement a été effectué avec succès, mais nous n'avons pas pu enregistrer votre carte bancaire. admin: oauth_callback_success: La connexion OAuth a été établie avec succès. payment_method_disabled: ⚠️ La méthode de paiement a été désactivée car certains critères de validation ne sont pas remplis. oauth_setup_error: Une erreur s'est produite lors de la configuration de la connexion OAuth. Veuillez réessayer. + logout_success: Le compte PayPlug a été déconnecté et ce moyen de paiement a été désactivé. + logout_error: Une erreur s’est produite lors de la déconnexion du compte PayPlug. Veuillez réessayer. + authorization: + capture_success: La capture a été effectuée. + cancel_success: L'annulation a été effectuée. + error: + not_authorized: Ce paiement n'est pas (ou plus) une autorisation en attente de capture. Rechargez la page pour voir son état actuel. + stale: La commande a changé depuis l'affichage de la page (opération déjà effectuée ?). Rechargez la page et vérifiez le montant restant avant de réessayer. + in_progress: Une opération est déjà en cours sur ce paiement. Patientez quelques secondes puis rechargez la page. + expired: L'autorisation a expiré, les fonds ont été libérés. Aucune capture n'est plus possible ; demandez un nouveau paiement au client si nécessaire. + cancel_after_capture: Une capture a déjà été effectuée sur ce paiement, il ne peut plus être annulé. Utilisez le remboursement pour rendre tout ou partie du montant capturé. + nothing_remaining: Il ne reste aucun montant à capturer ou annuler sur cette autorisation. + invalid_amount: "Montant invalide. Saisissez un montant positif, par exemple 12,50, ou laissez le champ vide pour le montant restant." + unsupported_currency: "Impossible de saisir un montant pour un paiement dans cette devise (%currency%). Laissez le champ vide pour utiliser tout le montant restant. Le paiement n'a pas été modifié." + amount_exceeds_remaining: "Le montant dépasse le restant disponible (%remaining%). Saisissez un montant inférieur ou égal." + amount_refused: PayPlug a refusé ce montant (supérieur au montant disponible ou invalide). Rechargez la page et vérifiez le montant restant. + already_captured: PayPlug indique que ce paiement a déjà été capturé. Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + already_cancelled: PayPlug indique que ce paiement a déjà été annulé. Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + not_operable: PayPlug indique que ce paiement n'est plus dans un état permettant cette opération (déjà capturé ou annulé). Rechargez la page ; si l'état affiché ne correspond pas, contactez le support PayPlug. + partial_cancellation_not_allowed: L'annulation partielle n'est pas activée sur votre contrat PayPlug. Annulez la totalité (champ montant vide) ou contactez PayPlug pour activer l'option. + conflict: Une autre opération est en cours de traitement chez PayPlug pour ce paiement. Réessayez dans quelques instants. + refused_by_issuer: La banque du client a refusé l'opération. Le paiement n'a pas été modifié. + multiple_capture_not_allowed: Une seule capture est possible sur cette autorisation avec votre compte PayPlug ; la capture précédente a déjà été effectuée. Le montant restant ne peut pas être capturé. Pour les prochaines commandes, capturez le montant final en une seule fois, ou contactez PayPlug pour activer la capture multiple. + payment_not_found: Ce paiement est introuvable chez PayPlug. Vérifiez que le moyen de paiement est connecté au bon compte PayPlug. + api_error: L'opération n'a pas pu être effectuée à cause d'une erreur PayPlug. Le paiement n'a pas été modifié ; réessayez dans quelques instants. + unexpected_error: L'opération a peut-être été effectuée par PayPlug, mais elle n'a pas pu être enregistrée ici. Vérifiez le paiement dans votre portail PayPlug avant de réessayer, pour ne pas capturer ou annuler deux fois. diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 10cd5113..764f2bbe 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -3,9 +3,36 @@ payplug_sylius_payplug_plugin: oney_not_enabled: Oney non è o non è più attivato per il tuo account. api_unknow_error: C'è stato un errore. Per favore riprova tra qualche secondo. transaction_failed_1click: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta. + transaction_failed: La transazione non è stata conclusa e non è stato effettuato alcun addebito sulla tua carta. warning: payment_success_no_card_saved: Il pagamento è andato a buon fine ma non è stato possibile salvare la tua carta di credito. admin: oauth_callback_success: La connessione OAuth è stata stabilita con successo. payment_method_disabled: ⚠️ Il metodo di pagamento è stato disattivato perché alcuni criteri di validazione non sono soddisfatti. oauth_setup_error: Si è verificato un errore durante la configurazione della connessione OAuth. Per favore riprova. + logout_success: L’account PayPlug è stato disconnesso e questo metodo di pagamento è stato disattivato. + logout_error: Si è verificato un errore durante la disconnessione dell’account PayPlug. Per favore riprova. + authorization: + capture_success: L'incasso è stato effettuato. + cancel_success: L'annullamento è stato effettuato. + error: + not_authorized: Questo pagamento non è (o non è più) un'autorizzazione in attesa di incasso. Ricarica la pagina per vederne lo stato attuale. + stale: L'ordine è cambiato dalla visualizzazione della pagina (operazione già eseguita?). Ricarica la pagina e verifica l'importo residuo prima di riprovare. + in_progress: Un'altra operazione è già in corso su questo pagamento. Attendi qualche secondo, poi ricarica la pagina. + expired: L'autorizzazione è scaduta e i fondi sono stati rilasciati. Non è più possibile alcun incasso; se necessario, chiedi al cliente un nuovo pagamento. + cancel_after_capture: Su questo pagamento è già stato effettuato un incasso, quindi non può più essere annullato. Usa un rimborso per restituire tutto o parte dell'importo incassato. + nothing_remaining: Non resta alcun importo da incassare o annullare su questa autorizzazione. + invalid_amount: "Importo non valido. Inserisci un importo positivo, ad es. 12,50, oppure lascia il campo vuoto per l'importo residuo." + unsupported_currency: "Non è possibile inserire un importo per un pagamento in questa valuta (%currency%). Lascia il campo vuoto per usare l'intero importo residuo. Il pagamento non è stato modificato." + amount_exceeds_remaining: "L'importo supera quello ancora disponibile (%remaining%). Inserisci un importo inferiore o uguale." + amount_refused: PayPlug ha rifiutato questo importo (superiore a quello disponibile o non valido). Ricarica la pagina e verifica l'importo residuo. + already_captured: PayPlug indica che questo pagamento è già stato incassato. Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + already_cancelled: PayPlug indica che questo pagamento è già stato annullato. Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + not_operable: PayPlug indica che questo pagamento non è più in uno stato che consente questa operazione (già incassato o annullato). Ricarica la pagina; se lo stato mostrato non corrisponde, contatta il supporto PayPlug. + partial_cancellation_not_allowed: L'annullamento parziale non è attivo sul tuo contratto PayPlug. Annulla l'intero importo (campo importo vuoto) o contatta PayPlug per attivare l'opzione. + conflict: Un'altra operazione è in corso di elaborazione presso PayPlug per questo pagamento. Riprova tra qualche istante. + refused_by_issuer: La banca del cliente ha rifiutato l'operazione. Il pagamento non è stato modificato. + multiple_capture_not_allowed: Con il tuo account PayPlug è possibile un solo incasso su questa autorizzazione, ed è già stato effettuato. L'importo residuo non può essere incassato. Per i prossimi ordini, incassa l'importo finale in una sola volta, oppure contatta PayPlug per attivare l'incasso multiplo. + payment_not_found: Questo pagamento non è stato trovato presso PayPlug. Verifica che il metodo di pagamento sia collegato all'account PayPlug corretto. + api_error: L'operazione non è stata eseguita a causa di un errore PayPlug. Il pagamento non è stato modificato; riprova tra qualche istante. + unexpected_error: L'operazione potrebbe essere stata eseguita da PayPlug, ma non è stato possibile registrarla qui. Verifica il pagamento nel tuo portale PayPlug prima di riprovare, per non catturare o annullare due volte. diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 9ffa6acb..c968ddb0 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Without Fees oney_x4_without_fees_payment: Oney 4x Without Fees + authorization: + title: PayPlug — Deferred capture + authorized_amount: Authorized amount + captured_amount: Already captured + cancelled_amount: Cancelled + remaining_amount: Remaining capturable + max_capture_date: Capture deadline + max_capture_date_unknown: Not provided + expired: The authorization has expired and the funds have been released. No capture is possible any more. + expiring_soon: The authorization expires soon. Capture the amount you need before the deadline, or the funds will be released. + capture_amount: Amount to capture + capture: Capture + capture_help: Leave blank to capture the whole remaining amount. You may capture several times as long as the total stays within the authorized amount. + cancel_amount: Amount to cancel + cancel: Cancel authorization + cancel_help: Leave blank to cancel everything. Partial cancellation must be enabled on your PayPlug contract. Not possible after a first capture. + cancel_confirm: Confirm the cancellation? The cancelled funds will be released and can no longer be captured. + nothing_remaining: Nothing left to capture on this authorization. + not_authorized: No action available in the payment's current state. ui: account: saved_cards: @@ -34,6 +53,7 @@ payplug_sylius_payplug_plugin: shipping.postcode: The postal code %postalCode% entered for the delivery address is invalid payplug_gateway_label: Payplug payment_refund_locally: The payment refund was made locally only. + payment_refund_missing_payment_id: 'This payment cannot be refunded from the back office: Payplug did not return its payment reference. Contact Payplug support mentioning order %order%.' test_mode: TEST Mode payplug_id: | Payplug ID: %paymentId% @@ -93,6 +113,10 @@ payplug_sylius_payplug_plugin: title: 'The fees are:' client: Split between you and your customers merchant: For you + scalapay_gateway_config: + min_amount: Minimum amount + max_amount: Maximum amount + amount_help: Leave empty to use the limits authorized by PayPlug. integrated_payment: card_holder.title: 'Cardholder name' card_holder.error: 'Invalid Name and/or Last Name.' @@ -107,6 +131,13 @@ payplug_sylius_payplug_plugin: place_order.label: 'Place order' transaction_secure.label: 'Transaction secured by' privacy_policy.label: 'Privacy Policy' + hosted_fields: + error.tokenization_failed: 'Your card details could not be validated. Please check them and try again.' + error.unsupported_brand: 'This card brand is not supported for this payment method. Please use a different card.' + save_card.label: 'Save my card' + place_order.label: 'Place order' + network_selection.label: 'Select your network' + network_selection.tooltip: 'Your card supports two networks and you can choose the one you want for this payment. If you do not, the network selected by default by the merchant will be used.' deferred_capture: process_order_info: | You will be charged when your order is processed. @@ -117,6 +148,13 @@ payplug_sylius_payplug_plugin: renew_oauth: Force OAuth reconnection renew_oauth_help: | If this option is checked, a new authentication flow will be started when clicking the "Update" button. + hf_identifier_label: 'Account ID' + hosted_fields_option: 'Advanced (beta) Hosted Fields' + connected_account: Connected PayPlug account + connected_account_unknown: Re-authenticate to display the connected account + not_connected: No PayPlug account connected yet + logout: Disconnect this account + logout_help: Removes the PayPlug credentials stored for this payment method and disables it. The other payment methods keep their own connection. form: oney_error: Some missing information is required to pay using Oney by Payplug complete_info: @@ -130,13 +168,16 @@ payplug_sylius_payplug_plugin: submit: Confirm and continue base_currency_not_euro: | Channel #channel_code#: #payment_method# is only available on channels with EURO as a currency - only_one_gateway_allowed: | - Please note that the %gateway_title% payment method has already been set. To change it, go to your payment methods. + gateway_channel_conflict: >- + Channel %channel% is already linked to the enabled payment method "%payment_method%". Disable it, or remove that channel from one of the two. one_click_enable: Enable One click one_click_help: | Allow your customers to save their credit card details for later integrated_payment_enable: Enable payment integrated + redirected_payment_enable: Enable redirected payment + account_id_required: 'Advanced features require an Account ID, please contact your Account Manager.' + integrated_payment_currency_incompatible: 'The selected channel is not compatible with Integrated Payment.' deferred_capture_enable: Enable deferred capture deferred_capture_help: | diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 068fba68..0050815b 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Sans Frais oney_x4_without_fees_payment: Oney 4x Sans Frais + authorization: + title: PayPlug — Capture différée + authorized_amount: Montant autorisé + captured_amount: Déjà capturé + cancelled_amount: Annulé + remaining_amount: Restant capturable + max_capture_date: Date limite de capture + max_capture_date_unknown: Non communiquée + expired: L'autorisation a expiré, les fonds ont été libérés. Aucune capture n'est plus possible. + expiring_soon: L'autorisation expire bientôt. Capturez le montant souhaité avant la date limite, sinon les fonds seront libérés. + capture_amount: Montant à capturer + capture: Capturer + capture_help: Laissez vide pour capturer tout le restant. Vous pouvez capturer en plusieurs fois tant que le cumul reste dans le montant autorisé. + cancel_amount: Montant à annuler + cancel: Annuler l'autorisation + cancel_help: Laissez vide pour tout annuler. L'annulation partielle doit être activée sur votre contrat PayPlug. Impossible après une première capture. + cancel_confirm: Confirmer l'annulation ? Les fonds annulés seront libérés et ne pourront plus être capturés. + nothing_remaining: Plus rien à capturer sur cette autorisation. + not_authorized: Aucune action disponible dans l'état actuel du paiement. ui: account: saved_cards: @@ -34,6 +53,7 @@ payplug_sylius_payplug_plugin: shipping.postcode: Le code postal %postalCode% renseigné pour l'adresse de livraison est invalide payplug_gateway_label: Payplug payment_refund_locally: Le remboursement du paiement a été effectué uniquement localement. + payment_refund_missing_payment_id: "Ce paiement ne peut pas être remboursé depuis le back-office : Payplug n'a pas renvoyé sa référence de paiement. Contactez le support Payplug en indiquant la commande %order%." test_mode: Mode TEST payplug_id: | ID Payplug : %paymentId% @@ -111,6 +131,10 @@ payplug_sylius_payplug_plugin: title: 'Les frais sont :' client: Répartis entre vous et vos clients merchant: À votre charge + scalapay_gateway_config: + min_amount: Montant minimum + max_amount: Montant maximum + amount_help: Laissez vide pour utiliser les limites autorisées par PayPlug. integrated_payment: card_holder.title: 'Nom du titulaire de la carte' card_holder.error: 'Nom et/ou prénom invalide(s).' @@ -126,6 +150,13 @@ payplug_sylius_payplug_plugin: place_order.label: 'Confirmer le paiement' transaction_secure.label: 'Transaction sécurisée par' privacy_policy.label: 'Politique de confidentialité' + hosted_fields: + error.tokenization_failed: 'Les informations de votre carte n’ont pas pu être validées. Veuillez les vérifier et réessayer.' + error.unsupported_brand: 'Cette marque de carte n’est pas prise en charge pour ce moyen de paiement. Merci d’utiliser une autre carte.' + save_card.label: 'Enregistrer ma carte bancaire' + place_order.label: 'Confirmer le paiement' + network_selection.label: 'Sélectionnez votre réseau' + network_selection.tooltip: 'Votre carte supporte deux réseaux et vous pouvez choisir celui que vous voulez pour ce paiement. Si vous ne le faites pas, alors le réseau sera celui sélectionné par défaut par le marchand.' deferred_capture: process_order_info: | Vous serez prélevé(é) lors du traitement de votre commande. @@ -137,6 +168,13 @@ payplug_sylius_payplug_plugin: renew_oauth: Forcer la reconnexion OAuth renew_oauth_help: | Si cette option est cochée, un nouveau flux d’authentification sera lancé lors du clic sur le bouton "Mise à jour". + hf_identifier_label: 'Identifiant de compte' + hosted_fields_option: 'Hosted Fields avancé (beta)' + connected_account: Compte PayPlug connecté + connected_account_unknown: Reconnectez-vous pour afficher le compte connecté + not_connected: Aucun compte PayPlug connecté pour le moment + logout: Déconnecter ce compte + logout_help: Supprime les identifiants PayPlug enregistrés pour ce moyen de paiement et le désactive. Les autres moyens de paiement conservent leur propre connexion. form: oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug complete_info: @@ -150,14 +188,17 @@ payplug_sylius_payplug_plugin: submit: Valider et continuer base_currency_not_euro: | Canal #channel_code# : #payment_method# n’est disponible que sur des canaux dont la devise est l’EURO - only_one_gateway_allowed: | - Attention, le moyen de paiement %gateway_title% existe déjà. Pour le modifier, rendez-vous sur vos moyens de paiement. + gateway_channel_conflict: >- + Le canal %channel% est déjà rattaché au moyen de paiement actif « %payment_method% ». Désactivez-le, ou retirez ce canal de l'un des deux. one_click_enable: Activer le One click one_click_help: | Permettez à vos clients d'enregistrer leurs coordonnées de carte de paiement pour effectuer ultérieurement d'autres transactions integrated_payment_enable: Activer le Paiement Integré + redirected_payment_enable: Activer le Paiement redirigé + account_id_required: 'Les fonctionnalités avancées nécessitent un Account ID, veuillez contacter votre Account Manager.' + integrated_payment_currency_incompatible: 'Le canal sélectionné n''est pas compatible avec le Paiement intégré' deferred_capture_enable: Activer la capture différée deferred_capture_help: | Attention, assurez vous qu'un déclencheur de la capture différée a bien été ajouté dans le code source du projet diff --git a/translations/messages.it.yml b/translations/messages.it.yml index 10459f42..086dab6e 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -5,6 +5,25 @@ payplug_sylius_payplug_plugin: oney_x4_with_fees_payment: Oney 4x oney_x3_without_fees_payment: Oney 3x Nessuna Commissione oney_x4_without_fees_payment: Oney 4x Nessuna Commissione + authorization: + title: PayPlug — Incasso differito + authorized_amount: Importo autorizzato + captured_amount: Già incassato + cancelled_amount: Annullato + remaining_amount: Residuo incassabile + max_capture_date: Scadenza per l'incasso + max_capture_date_unknown: Non comunicata + expired: L'autorizzazione è scaduta e i fondi sono stati rilasciati. Non è più possibile alcun incasso. + expiring_soon: L'autorizzazione scade a breve. Incassa l'importo desiderato prima della scadenza, altrimenti i fondi verranno rilasciati. + capture_amount: Importo da incassare + capture: Incassa + capture_help: Lascia vuoto per incassare tutto il residuo. Puoi incassare più volte finché il totale resta entro l'importo autorizzato. + cancel_amount: Importo da annullare + cancel: Annulla autorizzazione + cancel_help: Lascia vuoto per annullare tutto. L'annullamento parziale deve essere attivo sul tuo contratto PayPlug. Non possibile dopo un primo incasso. + cancel_confirm: Confermare l'annullamento? I fondi annullati verranno rilasciati e non potranno più essere incassati. + nothing_remaining: Non resta nulla da incassare su questa autorizzazione. + not_authorized: Nessuna azione disponibile nello stato attuale del pagamento. ui: account: saved_cards: @@ -34,6 +53,7 @@ payplug_sylius_payplug_plugin: shipping.postcode: Il codice postale %postalCode% inserito per l’indirizzo di consegna non è valido payplug_gateway_label: Payplug payment_refund_locally: Il rimborso del pagamento è stato effettuato solo a livello locale (interno). + payment_refund_missing_payment_id: 'Questo pagamento non può essere rimborsato dal back office: Payplug non ha restituito il suo riferimento di pagamento. Contatta il supporto Payplug indicando l''ordine %order%.' test_mode: Modalità TEST payplug_id: | ID Payplug : %paymentId% @@ -93,6 +113,10 @@ payplug_sylius_payplug_plugin: title: 'Le spese sono:' client: Ripartite tra te e i tuoi clienti merchant: A tuo carico + scalapay_gateway_config: + min_amount: Importo minimo + max_amount: Importo massimo + amount_help: Lascia vuoto per usare i limiti autorizzati da PayPlug. integrated_payment: card_holder.title: 'Titolare della carta' card_holder.error: 'Nome e/o Cognome non valido(i).' @@ -107,6 +131,13 @@ payplug_sylius_payplug_plugin: place_order.label: 'Ordine' transaction_secure.label: 'Transazione protetta da' privacy_policy.label: 'Politica di confidenzialità' + hosted_fields: + error.tokenization_failed: 'Non è stato possibile verificare i dati della tua carta. Controllali e riprova.' + error.unsupported_brand: 'Questo marchio di carta non è supportato per questo metodo di pagamento. Utilizza un’altra carta.' + save_card.label: 'Salva la mia carta' + place_order.label: 'Ordine' + network_selection.label: 'Seleziona il tuo circuito' + network_selection.tooltip: 'La tua carta supporta due circuiti e puoi scegliere quello che preferisci per questo pagamento. Se non lo fai, verrà utilizzato il circuito selezionato di default dal commerciante.' deferred_capture: process_order_info: | L'addebito avverrà al momento dell'elaborazione dell'ordine. @@ -117,6 +148,13 @@ payplug_sylius_payplug_plugin: renew_oauth: Forza la riconnessione OAuth renew_oauth_help: | Se questa opzione è selezionata, un nuovo flusso di autenticazione verrà avviato quando si fa clic sul pulsante "Aggiorna". + hf_identifier_label: 'ID Account' + hosted_fields_option: 'Hosted Fields avanzato (beta)' + connected_account: Account PayPlug collegato + connected_account_unknown: Autenticati di nuovo per visualizzare l'account collegato + not_connected: Nessun account PayPlug ancora collegato + logout: Disconnetti questo account + logout_help: Rimuove le credenziali PayPlug salvate per questo metodo di pagamento e lo disattiva. Gli altri metodi di pagamento mantengono la propria connessione. form: oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug” complete_info: @@ -130,13 +168,16 @@ payplug_sylius_payplug_plugin: submit: Convalida e continua base_currency_not_euro: | Il canale #channel_code# : #payment_method# è disponibile solo per i canali la cui valuta è in EURO - only_one_gateway_allowed: | - Attenzione: il metodo di pagamento %gateway_title% è già definito. Per modificarlo, vai ai tuoi metodi di pagamento. + gateway_channel_conflict: >- + Il canale %channel% è già collegato al metodo di pagamento attivo "%payment_method%". Disattivalo oppure rimuovi quel canale da uno dei due. one_click_enable: Attiva un clic one_click_help: | Consenti ai tuoi clienti di salvare i dettagli della loro carta di credito per dopo integrated_payment_enable: Abilita il pagamento integrato + redirected_payment_enable: Abilita il pagamento reindirizzato + account_id_required: 'Le funzionalità avanzate richiedono un Account ID, si prega di contattare il vostro Account Manager.' + integrated_payment_currency_incompatible: 'Il canale selezionato non è compatibile con il Pagamento integrato.' deferred_capture_enable: Abilita la cattura differita deferred_capture_help: | Assicurarsi che nel codice sorgente del progetto sia stato aggiunto un trigger di acquisizione ritardata diff --git a/translations/validators.en.yml b/translations/validators.en.yml index 78c1c1fe..cf4141c2 100644 --- a/translations/validators.en.yml +++ b/translations/validators.en.yml @@ -33,6 +33,8 @@ payplug_sylius_payplug_plugin: You don't have access to this feature yet. To activate Scalapay, please contact us at support@payplug.com and activate the LIVE mode. + min_amount_greater_than_max: The minimum amount must be lower than or equal to the maximum amount. + amount_out_of_authorized_range: The amount limits must be between %min_amount% and %max_amount%. payplug_wero: can_not_save_method_with_test_key: | The Wero payment method is not available for the TEST mode. diff --git a/translations/validators.fr.yml b/translations/validators.fr.yml index 6031253d..5f4baffc 100644 --- a/translations/validators.fr.yml +++ b/translations/validators.fr.yml @@ -32,6 +32,8 @@ payplug_sylius_payplug_plugin: Vous n'avez pas accès à cette fonctionnalité. Pour activer Scalapay, contactez-nous à support@payplug.com et activez le mode LIVE. + min_amount_greater_than_max: Le montant minimum doit être inférieur ou égal au montant maximum. + amount_out_of_authorized_range: Les limites de montant doivent être comprises entre %min_amount% et %max_amount%. payplug_wero: can_not_save_method_with_test_key: | Le paiement par Wero n'est pas disponible en mode TEST. diff --git a/translations/validators.it.yml b/translations/validators.it.yml index 7dcf67b4..7ebaea99 100644 --- a/translations/validators.it.yml +++ b/translations/validators.it.yml @@ -32,6 +32,8 @@ payplug_sylius_payplug_plugin: Non puoi ancora accedere a questa funzionalità. Per attivare Scalapay, contattaci a support@payplug.com e attiva la modalità LIVE. + min_amount_greater_than_max: L'importo minimo deve essere inferiore o uguale all'importo massimo. + amount_out_of_authorized_range: I limiti di importo devono essere compresi tra %min_amount% e %max_amount%. payplug_wero: can_not_save_method_with_test_key: | Il metodo di pagamento Wero non è disponibile in modalità TEST.