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/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..149ac881 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 ### ¯¯¯¯¯¯ @@ -36,8 +50,6 @@ install-sylius: ${COMPOSER} require --dev sylius/test-application:"^${SYLIUS_VERSION}@alpha" -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/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..bfb08ebb --- /dev/null +++ b/assets/shop/dist/payment/hosted-fields.css @@ -0,0 +1,226 @@ +.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; + align-items: center; + gap: 4px +} + +.payplugHostedFields__cardWrapper .payplugHostedFields__container--card { + flex: 1; + min-width: 0; + margin: 0 +} + +.payplugHostedFields__container--brand { + height: 42px; + min-height: 0; + width: 90px; + 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..4edd883c 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.1.0", "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..743aa692 --- /dev/null +++ b/composer.lock @@ -0,0 +1,21940 @@ +{ + "_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": "de5c8fb480fe990a5976e263500c12e2", + "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.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/doctrine-common.git", + "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/45057f5226c3bdfbb0803220a9cfa667b5da83ad", + "reference": "45057f5226c3bdfbb0803220a9cfa667b5da83ad", + "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.18" + }, + "time": "2026-08-16T07:56:59+00:00" + }, + { + "name": "api-platform/doctrine-orm", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/doctrine-orm.git", + "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", + "reference": "a8831db3ffed64cfc55ceac4f43e600eb6bf3bb5", + "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.18" + }, + "time": "2026-07-22T15:09:18+00:00" + }, + { + "name": "api-platform/documentation", + "version": "v4.3.18", + "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.18", + "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.18", + "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.18" + }, + "time": "2026-08-16T15:12:48+00:00" + }, + { + "name": "api-platform/json-schema", + "version": "v4.3.18", + "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.18" + }, + "time": "2026-09-02T08:21:34+00:00" + }, + { + "name": "api-platform/jsonld", + "version": "v4.3.18", + "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.18" + }, + "time": "2026-06-13T05:11:46+00:00" + }, + { + "name": "api-platform/metadata", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/metadata.git", + "reference": "cff651763e34d72195f516e4c36790ace623dfe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/cff651763e34d72195f516e4c36790ace623dfe4", + "reference": "cff651763e34d72195f516e4c36790ace623dfe4", + "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.18" + }, + "time": "2026-09-04T08:39:01+00:00" + }, + { + "name": "api-platform/openapi", + "version": "v4.3.18", + "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.18" + }, + "time": "2026-06-16T10:01:53+00:00" + }, + { + "name": "api-platform/serializer", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/serializer.git", + "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", + "reference": "7b1237b6fe3b5a84eba9f44a97f23b99ea267e0c", + "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.18" + }, + "time": "2026-08-16T16:40:51+00:00" + }, + { + "name": "api-platform/state", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/state.git", + "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/state/zipball/7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", + "reference": "7a50bbef781fc0e98df30d0d99b630d1d27c5ab6", + "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.18" + }, + "time": "2026-08-16T07:58:36+00:00" + }, + { + "name": "api-platform/symfony", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/symfony.git", + "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/42e4ff04ef9183f45ebaffcd17681a3d6710adb9", + "reference": "42e4ff04ef9183f45ebaffcd17681a3d6710adb9", + "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.18" + }, + "time": "2026-09-02T20:01:30+00:00" + }, + { + "name": "api-platform/validator", + "version": "v4.3.18", + "source": { + "type": "git", + "url": "https://github.com/api-platform/validator.git", + "reference": "6df6804799f8831469d2602d0845a0316e81fbab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/validator/zipball/6df6804799f8831469d2602d0845a0316e81fbab", + "reference": "6df6804799f8831469d2602d0845a0316e81fbab", + "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.18" + }, + "time": "2026-05-07T11:45:31+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": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "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/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+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.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "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": "Semver 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.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+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.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "07b90f707b82981097731c419f546e7ba97fba3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c", + "reference": "07b90f707b82981097731c419f546e7ba97fba3c", + "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.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%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2026-07-23T14:52:05+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.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "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.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%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "doctrine/migrations", + "version": "3.9.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "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.7" + }, + "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-04-23T19:33:20+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.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git", + "reference": "a973a68a990392b96f5ccbd4d713ae33e4bde95f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/a973a68a990392b96f5ccbd4d713ae33e4bde95f", + "reference": "a973a68a990392b96f5ccbd4d713ae33e4bde95f", + "shasum": "" + }, + "require": { + "doctrine/collections": "^1.2 || ^2.0", + "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.1" + }, + "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-08-01T13:57:40+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.17.2", + "source": { + "type": "git", + "url": "https://github.com/liip/LiipImagineBundle.git", + "reference": "548846e07d1b928890772d245c2e31aaa45706b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/548846e07d1b928890772d245c2e31aaa45706b5", + "reference": "548846e07d1b928890772d245c2e31aaa45706b5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "imagine/imagine": "^1.3.2", + "php": "^8.0", + "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.17.2" + }, + "time": "2026-08-05T12:56:26+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.11.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "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", + "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", + "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.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:39:56+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.8.0", + "source": { + "type": "git", + "url": "https://github.com/BabDev/Pagerfanta.git", + "reference": "72881e6839330b2961c574b3b4b20d409d6a0955" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/72881e6839330b2961c574b3b4b20d409d6a0955", + "reference": "72881e6839330b2961c574b3b4b20d409d6a0955", + "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.1.36", + "phpstan/phpstan-phpunit": "2.0.11", + "phpunit/phpunit": "10.5.60", + "rector/rector": "2.3.4", + "ruflin/elastica": "^7.3 || ^8.0", + "solarium/solarium": "^6.2", + "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "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.8.0" + }, + "funding": [ + { + "url": "https://github.com/mbabker", + "type": "github" + } + ], + "time": "2026-01-22T13:58:52+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.1.1", + "source": { + "type": "git", + "url": "https://github.com/payplug/unified-plugin-core.git", + "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/payplug/unified-plugin-core/zipball/fd22a11adef6a3b9833fc986111ecf7973c59928", + "reference": "fd22a11adef6a3b9833fc986111ecf7973c59928", + "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.1.1" + }, + "time": "2026-09-07T12:40:07+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.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "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.3" + }, + "time": "2026-06-18T03:57:49+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.0", + "source": { + "type": "git", + "url": "https://github.com/Sylius/SyliusMailerBundle.git", + "reference": "56e90d6f5225c1d9c5444722c62f5a9bc18bd786" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/SyliusMailerBundle/zipball/56e90d6f5225c1d9c5444722c62f5a9bc18bd786", + "reference": "56e90d6f5225c1d9c5444722c62f5a9bc18bd786", + "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.0" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-02-06T10:52:59+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.1.0", + "source": { + "type": "git", + "url": "https://github.com/Sylius/RefundPlugin.git", + "reference": "5161e116d02ee7398182858be0e892d7f6a31172" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/RefundPlugin/zipball/5161e116d02ee7398182858be0e892d7f6a31172", + "reference": "5161e116d02ee7398182858be0e892d7f6a31172", + "shasum": "" + }, + "require": { + "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" + }, + "require-dev": { + "behat/behat": "^3.6.1", + "dmore/behat-chrome-extension": "^1.3", + "dmore/chrome-mink-driver": "^2.7", + "friends-of-behat/mink": "^1.8", + "friends-of-behat/mink-browserkit-driver": "^1.4", + "friends-of-behat/mink-debug-extension": "^2.0", + "friends-of-behat/mink-extension": "^2.4", + "friends-of-behat/page-object-extension": "^0.3", + "friends-of-behat/suite-settings-extension": "^1.0", + "friends-of-behat/symfony-extension": "^2.1", + "friends-of-behat/variadic-extension": "^1.3", + "matthiasnoback/symfony-config-test": "^5.1", + "matthiasnoback/symfony-dependency-injection-test": "^5.1", + "phpstan/phpstan": "^1.6", + "phpstan/phpstan-webmozart-assert": "^1.1", + "phpunit/phpunit": "^10.5", + "sylius-labs/coding-standard": "^4.2", + "sylius/test-application": "^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/intl": "^6.4 || ^7.4", + "symfony/web-profiler-bundle": "^6.4 || ^7.4", + "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.1.0" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-05-06T06:07:12+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.9", + "source": { + "type": "git", + "url": "https://github.com/Sylius/Sylius.git", + "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Sylius/Sylius/zipball/253169df80a8c8babf479c6de5ce3d422e37dc6a", + "reference": "253169df80a8c8babf479c6de5ce3d422e37dc6a", + "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", + "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.9" + }, + "funding": [ + { + "url": "https://github.com/sylius", + "type": "github" + } + ], + "time": "2026-09-02T13:54:32+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "ea9758447aa48581e2e2c0d318782d89e2182136" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/ea9758447aa48581e2e2c0d318782d89e2182136", + "reference": "ea9758447aa48581e2e2c0d318782d89e2182136", + "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.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/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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "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.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-25T13:08:31+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/675b2dfaf70bdca37d13816582023e3bac5fc2d1", + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1", + "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.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:20:39+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/57ddd0c22de16bf22e10d810d323027bf4e532ac", + "reference": "57ddd0c22de16bf22e10d810d323027bf4e532ac", + "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.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-30T18:34:07+00:00" + }, + { + "name": "symfony/doctrine-messenger", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-messenger.git", + "reference": "21e0d6e210270d1ebff2fa0dace165627c49119f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/21e0d6e210270d1ebff2fa0dace165627c49119f", + "reference": "21e0d6e210270d1ebff2fa0dace165627c49119f", + "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", + "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.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-06-16T14:52:47+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8fa59eb915a14b2881993565a122c9dd1b0f8a19", + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19", + "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.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:15+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.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/211b28d13d044dacdc00c1629a3bbcff27dc793a", + "reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a", + "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.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-21T10:00:03+00:00" + }, + { + "name": "symfony/form", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "03810456895dfc7e7fca450806e4eb21669927c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/03810456895dfc7e7fca450806e4eb21669927c8", + "reference": "03810456895dfc7e7fca450806e4eb21669927c8", + "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.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-29T12:19:25+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a19f20ad64c9b727ce21421638e7c92fa381ae9d", + "reference": "a19f20ad64c9b727ce21421638e7c92fa381ae9d", + "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", + "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.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/http-client", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "149b874c2f65b68032a0409288ca48667864e43d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/149b874c2f65b68032a0409288ca48667864e43d", + "reference": "149b874c2f65b68032a0409288ca48667864e43d", + "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.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-30T13:44:49+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", + "reference": "945bfd2bcccca941f75ce45d5530b36d4cc6dc0b", + "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.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/http-kernel", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", + "reference": "e5e8372b0e16ee6d6afa8fba37f23c25822dbfe2", + "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.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:52:35+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "84679f4725d2359f53739ad44e7fa1af9ee9a11d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/84679f4725d2359f53739ad44e7fa1af9ee9a11d", + "reference": "84679f4725d2359f53739ad44e7fa1af9ee9a11d", + "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.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/mailer", + "version": "v6.4.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/cea6ee2fb64d486f709d80bd3158cf28346e1ba4", + "reference": "cea6ee2fb64d486f709d80bd3158cf28346e1ba4", + "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.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-21T13:01:07+00:00" + }, + { + "name": "symfony/messenger", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "f0da814e78084005644e891e100773f2b07a69bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/f0da814e78084005644e891e100773f2b07a69bb", + "reference": "f0da814e78084005644e891e100773f2b07a69bb", + "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.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/mime", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/3f850171f3bb396a84117ca97d3d474fc4b83deb", + "reference": "3f850171f3bb396a84117ca97d3d474fc4b83deb", + "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.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-22T07:48:48+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.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57", + "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.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-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "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.41.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-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "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.38.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-25T11:52:53+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "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.42.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-08-24T10:51:20+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "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.42.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-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "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.38.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-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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.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-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "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.41.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-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "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.38.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-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "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.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/process", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/0b0c5b7d895211b82021469d1cb8ef2448caed96", + "reference": "0b0c5b7d895211b82021469d1cb8ef2448caed96", + "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.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-20T17:31:17+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/003ec301822cf2cf1b44b217fba1efdf4b4503f9", + "reference": "003ec301822cf2cf1b44b217fba1efdf4b4503f9", + "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.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-24T07:52:32+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/6fe2ed5b356beec41d5720dd73db72887c3f9d6d", + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d", + "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.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-16T17:07:07+00:00" + }, + { + "name": "symfony/security-bundle", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/de06cd0f756ca8fe4894c7e95bac71d90bb94e64", + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64", + "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.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-30T06:17:28+00:00" + }, + { + "name": "symfony/security-core", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "8f74c37460002b97c1318007629c8be37681498a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8f74c37460002b97c1318007629c8be37681498a", + "reference": "8f74c37460002b97c1318007629c8be37681498a", + "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.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:30:12+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/acc78bd71a614fc15a2931b08373ad6fd0fac9db", + "reference": "acc78bd71a614fc15a2931b08373ad6fd0fac9db", + "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.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:30:12+00:00" + }, + { + "name": "symfony/serializer", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", + "reference": "ad3c87d8d47e05e60fb8cd347348f2246c5e3aea", + "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.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-29T08:24:03+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.43", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "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.43" + }, + "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-28T07:28:15+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.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3036bcbdd27da00ded851b5049e1f73589c8d506", + "reference": "3036bcbdd27da00ded851b5049e1f73589c8d506", + "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.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-22T07:48:48+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v6.4.43", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/15bad71434a8bbc64db852a14ac6360af7f4ea4a", + "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a", + "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.43" + }, + "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-06T08:03:42+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "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.17" + }, + "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:52:48+00:00" + }, + { + "name": "symfony/uid", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "6b973c385f00341b246f697d82dc01a09107acdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/6b973c385f00341b246f697d82dc01a09107acdd", + "reference": "6b973c385f00341b246f697d82dc01a09107acdd", + "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.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": "2025-12-23T15:07:59+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.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", + "reference": "8acf2afad9c26cd2386d760ef9f0ec94c1c55b90", + "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.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:30:12+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": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/webpack-encore-bundle.git", + "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", + "reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", + "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/v2.4.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-24T07:21:58+00:00" + }, + { + "name": "symfony/workflow", + "version": "v6.4.37", + "source": { + "type": "git", + "url": "https://github.com/symfony/workflow.git", + "reference": "0624ea5019589fd6a941f0ff11b899c4caca4fc2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/workflow/zipball/0624ea5019589fd6a941f0ff11b899c4caca4fc2", + "reference": "0624ea5019589fd6a941f0ff11b899c4caca4fc2", + "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.37" + }, + "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-13T14:11:12+00:00" + }, + { + "name": "symfony/yaml", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a778aba2d7130eba6150cc80cb692988a361ee4b", + "reference": "a778aba2d7130eba6150cc80cb692988a361ee4b", + "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.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": "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.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "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.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-02-07T08:07:38+00:00" + }, + { + "name": "twig/intl-extra", + "version": "v3.26.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", + "reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", + "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.26.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-05-19T20:44:48+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.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "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.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-07-03T20:44:34+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.17.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": ">=8.1 <8.6" + }, + "require-dev": { + "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", + "friendsofphp/php-cs-fixer": "^3.77", + "mikey179/vfsstream": "^1.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^2", + "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.17.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-05-18T09:33:47+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.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "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-08-18T20:28:54+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.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "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.8.0" + }, + "time": "2026-07-04T14:30:18+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.23.0", + "source": { + "type": "git", + "url": "https://github.com/phpro/grumphp.git", + "reference": "c8a173464e319c81d530290c7642bfce482e05bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpro/grumphp/zipball/c8a173464e319c81d530290c7642bfce482e05bf", + "reference": "c8a173464e319c81d530290c7642bfce482e05bf", + "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.23.0" + }, + "time": "2026-07-22T11:36:59+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.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "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.5" + }, + "time": "2026-08-31T16:05:28+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.36", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "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.36" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-11T06:25:15+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.44", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a1a4e508411254392dbf5a2fb2390b11d790481d", + "reference": "a1a4e508411254392dbf5a2fb2390b11d790481d", + "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.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-22T06:28:57+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/services.yaml b/config/services.yaml index 7db6a5fe..6fc5f465 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,3 +1,19 @@ +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)%' + services: _defaults: autowire: true @@ -9,6 +25,10 @@ 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%' PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepositoryInterface: class: PayPlug\SyliusPayPlugPlugin\Repository\PaymentRepository @@ -25,6 +45,38 @@ 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 + + PayplugUnifiedCore\Contracts\IConfigurationRepository: + 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_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% + + + + 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..7736db9b 100644 --- a/ruleset/phpstan-baseline.neon +++ b/ruleset/phpstan-baseline.neon @@ -1252,18 +1252,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..4c5b7041 100644 --- a/src/Action/Admin/Auth/UnifiedAuthenticationController.php +++ b/src/Action/Admin/Auth/UnifiedAuthenticationController.php @@ -8,6 +8,8 @@ use Payplug\Authentication; use Payplug\Payplug; 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 +20,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 +31,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 +43,33 @@ public function __construct( private EntityManagerInterface $entityManager, private PaymentMethodValidator $paymentMethodValidator, private LoggerInterface $logger, - private CacheInterface $cache, + private IOAuthHttpClient $oauthHttpClient, + 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 +82,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 +117,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'); @@ -119,12 +131,6 @@ public function oauthCallback(Request $request): Response $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 +158,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/ApiClient/PayPlugApiClientFactory.php b/src/ApiClient/PayPlugApiClientFactory.php index 7d57cfe9..6b7d8f8a 100644 --- a/src/ApiClient/PayPlugApiClientFactory.php +++ b/src/ApiClient/PayPlugApiClientFactory.php @@ -4,19 +4,20 @@ 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, ) { } @@ -54,26 +55,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/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/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); + } catch (ApiException | InvalidPaymentException | \LogicException $e) { + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Alias); + + return; + } + + $payment->setDetails([ + ...$payment->getDetails(), + 'alias_id' => $card->getExternalId(), + 'alias_payment_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + ...$this->contextBuilder->resolveHostedFieldsIds($output->body), + ]); + + $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..a5efd925 --- /dev/null +++ b/src/Command/Handler/CaptureHostedPaymentRequestHandler.php @@ -0,0 +1,189 @@ +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); + } catch (ApiException | InvalidHostedFieldException | \LogicException $e) { + $this->outcomeApplier->failPaymentRequest($paymentRequest, $payment, $e, PaymentCaptureFlow::Hosted); + + return; + } + + $hostedFieldsIds = $this->contextBuilder->resolveHostedFieldsIds($output->body); + $payment->setDetails([ + ...$details, + 'hosted_fields_created_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + ...$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) : []; + $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): array + { + try { + $response = $this->operationStatusFetcher->getOperation($operationId); + } 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..00c07813 --- /dev/null +++ b/src/Command/Handler/NotifyHostedPaymentRequestHandler.php @@ -0,0 +1,136 @@ +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 { + $expectedHeader = $this->configurationRepository->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->orderStateMutator->apply($operationData->orderId, $operationData->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..cd9ba868 --- /dev/null +++ b/src/Command/Handler/StatusHostedPaymentRequestHandler.php @@ -0,0 +1,119 @@ +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 + { + if (\in_array($payment->getState(), self::RESOLVED_STATES, true)) { + return; + } + + $operationId = self::resolveOperationId($payment->getDetails()); + if (null === $operationId) { + return; + } + + try { + $response = $this->operationStatusFetcher->getOperation($operationId); + } 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; + } + + try { + $this->webhookNotificationHandler->treat($payment, $response['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(), + ]); + } + } + + /** @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/IpnAction.php b/src/Controller/IpnAction.php index ee7ca7c5..6f2332ff 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 { 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/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/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..168ebfbb 100644 --- a/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php +++ b/src/Gateway/Form/Type/AbstractGatewayConfigurationType.php @@ -69,6 +69,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (!$dataFormChannels instanceof Collection) { return; } + + $rawData = $event->getData(); + if (!\is_array($rawData) || !$this->shouldValidateBaseCurrency($rawData)) { + return; + } + + $flashedMessages = []; /** @var ChannelInterface $dataFormChannel */ foreach ($dataFormChannels as $key => $dataFormChannel) { $baseCurrency = $dataFormChannel->getBaseCurrency(); @@ -77,15 +84,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void } $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, - ], - ); + $message = $this->baseCurrencyViolationMessage($dataFormChannel); $formChannels->get((string) $key)->addError(new FormError($message)); - $this->requestStack->getSession()->getFlashBag()->add('error', $message); + if (!\in_array($message, $flashedMessages, true)) { + $flashedMessages[] = $message; + $this->requestStack->getSession()->getFlashBag()->add('error', $message); + } } } }) @@ -119,4 +123,36 @@ private function checkCreationRequirements( /* @phpstan-ignore-next-line */ $form->getParent()->getParent()->get('enabled')->addError(new FormError($message)); } + + /** + * Hook for subtypes to scope the base-currency-per-channel restriction below. + * 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 $rawFormData Raw PRE_SUBMIT data of the gateway config form. + */ + protected function shouldValidateBaseCurrency(array $rawFormData): bool + { + return true; + } + + /** + * 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. + */ + protected 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, + ], + ); + } } diff --git a/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php b/src/Gateway/Form/Type/PayPlugGatewayConfigurationType.php index f9056fdf..1344b3d1 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( @@ -22,4 +23,25 @@ final class PayPlugGatewayConfigurationType extends AbstractGatewayConfiguration 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. + * + * @param array $rawFormData + */ + protected function shouldValidateBaseCurrency(array $rawFormData): bool + { + return PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT === ($rawFormData[PayPlugGatewayFactory::DISPLAY_MODE_FIELD] ?? null); + } + + /** + * 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. + */ + protected function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + return $this->translator->trans('payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible'); + } } diff --git a/src/Gateway/PayPlugGatewayFactory.php b/src/Gateway/PayPlugGatewayFactory.php index 99cb0e41..5321a757 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,100 @@ 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); + } + + 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/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/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/Handler/HostedFieldsWebhookNotificationHandler.php b/src/Handler/HostedFieldsWebhookNotificationHandler.php new file mode 100644 index 00000000..659231ff --- /dev/null +++ b/src/Handler/HostedFieldsWebhookNotificationHandler.php @@ -0,0 +1,381 @@ + $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 + { + $expectedHeader = $this->configurationRepository->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); + $expectedAmount = $refundAmount ?? $payment->getAmount(); + + if (!$this->matchesPayment($payment, $operationData, $expectedAmount)) { + return; + } + + if (null !== $refundAmount) { + if (PaymentOutcome::PAID !== $operationData->outcome) { + // 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. + $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 actually being recorded. + return; + } + + $this->applyLocked($payment, $rawBody, $operationData, applyOutcome: false); + + return; + } + + $operationData->outcome = PaymentOutcome::REFUNDED; + } + + $this->applyLocked($payment, $rawBody, $operationData); + } + + // 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 treat()'s own non-success-refund branch 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); + + if (PaymentOutcome::PAID === $operationData->outcome) { + $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: confirmed the same paymentMethod.{id, card, details} shape as + // the operation resource CaptureHostedPaymentRequestHandler fetches separately, so no extra API + // call is needed here. + 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/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..b7fb4e9a 100644 --- a/src/PaymentProcessing/RefundPaymentProcessor.php +++ b/src/PaymentProcessing/RefundPaymentProcessor.php @@ -16,6 +16,10 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +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; @@ -34,6 +38,8 @@ #[Autoconfigure(public: true)] final class RefundPaymentProcessor implements PaymentProcessorInterface { + private const REFUND_LOCK_TTL_SECONDS = 30; + private PayPlugApiClientInterface $payPlugApiClient; public function __construct( @@ -43,6 +49,8 @@ public function __construct( private RepositoryInterface $refundPaymentRepository, private RefundHistoryRepositoryInterface $payplugRefundHistoryRepository, private PayPlugApiClientFactoryInterface $apiClientFactory, + private RefundCreatorInterface $refundCreator, + private ILock $lock, ) { } @@ -60,6 +68,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 +92,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 +134,298 @@ 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 + { + // Deliberately outside the try/catch below, mirroring the legacy path's own + // Assert::string($details['payment_id']) — a payment reaching here without this detail + // set raises a raw InvalidArgumentException rather than UpdateHandlingException. + Assert::string($payment->getDetails()['hosted_fields_payment_id']); + $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): 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, + $details['hosted_fields_payment_id'], + 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. + $refundedAmount = $originalAmount - 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 + { + // Deliberately outside the try/catch below — see processHostedFields()'s own comment. + Assert::string($payment->getDetails()['hosted_fields_payment_id']); + + /** @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): 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, + $details['hosted_fields_payment_id'], + 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); + }, + ); + } + + 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 +447,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/SupportedMethodsProvider.php b/src/Provider/SupportedMethodsProvider.php index 352665db..c9c0d4a4 100644 --- a/src/Provider/SupportedMethodsProvider.php +++ b/src/Provider/SupportedMethodsProvider.php @@ -5,26 +5,47 @@ 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(); + $activeCurrencyCode = $paymentCurrencyCode ?? $this->currencyContext->getCurrencyCode(); $authorizedCurrencies = null; $allowedCountries = null; @@ -48,59 +69,113 @@ 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'], + ]; + } - $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]]; - } + /** + * 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]; } - return $currencies; + return [$minAmount, $maxAmount]; + } + + /** + * @return array + */ + private function resolveAuthorizedCurrencies(string $factoryName): array + { + $account = $this->clientFactory->create($factoryName)->getAccount(); + $underscorePos = strpos($factoryName, '_'); + $paymentMethodKey = false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null; + + return $this->amountRangeResolver->resolve($account, $paymentMethodKey); } private function resolveAllowedCountries(string $factoryName): 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/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/PayPlugExtension.php b/src/Twig/PayPlugExtension.php index 9ebfcfc7..06a62756 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,7 @@ final class PayPlugExtension extends AbstractExtension { public function __construct( private CanSaveCardCheckerInterface $canSaveCardChecker, - private PayPlugApiClientFactory $apiClientFactory, + private PayPlugApiClientFactoryInterface $apiClientFactory, ) { } @@ -23,9 +24,19 @@ 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_display_mode', $this->displayMode(...)), ]; } + /** + * @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 +48,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/CardDataFromPaymentMethodExtractor.php b/src/Upc/CardDataFromPaymentMethodExtractor.php new file mode 100644 index 00000000..99e11af5 --- /dev/null +++ b/src/Upc/CardDataFromPaymentMethodExtractor.php @@ -0,0 +1,116 @@ + $aliasId] : []; + } + + /** @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..908aa127 --- /dev/null +++ b/src/Upc/PaymentCaptureContextBuilder.php @@ -0,0 +1,177 @@ +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); + // 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..bb24c40b --- /dev/null +++ b/src/Upc/PaymentCaptureOutcomeApplier.php @@ -0,0 +1,119 @@ +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); + } + + 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)) { + $this->orderStateMutator->apply(ResourceIdentifier::toString($payment->getId()), ExecCodeMapper::toPaymentOutcome($execCode)); + } + } +} 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..d6239ac3 --- /dev/null +++ b/src/Upc/SyliusUpcConfigurationRepository.php @@ -0,0 +1,88 @@ +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; + } + + private function findGatewayConfig(): GatewayConfigInterface + { + /** @var GatewayConfigInterface|null $gatewayConfig */ + $gatewayConfig = $this->gatewayConfigRepository->findOneBy(['factoryName' => PayPlugGatewayFactory::FACTORY_NAME]); + + return $gatewayConfig ?? throw new \LogicException('No gateway config found for ' . PayPlugGatewayFactory::FACTORY_NAME . '.'); + } +} 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/UnifiedApiOperationStatusFetcher.php b/src/Upc/UnifiedApiOperationStatusFetcher.php new file mode 100644 index 00000000..6645c453 --- /dev/null +++ b/src/Upc/UnifiedApiOperationStatusFetcher.php @@ -0,0 +1,34 @@ +httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $this->configurationRepository->getClientId(), + $this->configurationRepository->getClientSecret(), + ); + + return $service->getOperation($operationId); + } +} diff --git a/src/Upc/UnifiedApiPaymentCreator.php b/src/Upc/UnifiedApiPaymentCreator.php new file mode 100644 index 00000000..eac6108b --- /dev/null +++ b/src/Upc/UnifiedApiPaymentCreator.php @@ -0,0 +1,36 @@ +httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $this->configurationRepository->getClientId(), + $this->configurationRepository->getClientSecret(), + ); + + return $service->createPayment($dto); + } +} diff --git a/src/Upc/UnifiedApiPaymentCreatorInterface.php b/src/Upc/UnifiedApiPaymentCreatorInterface.php new file mode 100644 index 00000000..1dc79629 --- /dev/null +++ b/src/Upc/UnifiedApiPaymentCreatorInterface.php @@ -0,0 +1,21 @@ +httpClient, + $this->tokenManager, + $this->unifiedApiBaseUrl, + $this->configurationRepository->getClientId(), + $this->configurationRepository->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..ed2cf5ec 100644 --- a/src/Validator/PaymentMethodValidator.php +++ b/src/Validator/PaymentMethodValidator.php @@ -14,6 +14,7 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\ScalapayGatewayFactory; 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 +46,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,13 +69,13 @@ 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); } @@ -88,21 +89,7 @@ private function processOney(PaymentMethodInterface $paymentMethod): ConstraintV return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } - private function processBancontact(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processAmex(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface - { - $constraintList = [new IsCanSavePaymentMethod()]; - - return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); - } - - private function processApplePay(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface + private function processDefault(PaymentMethodInterface $paymentMethod): ConstraintViolationListInterface { $constraintList = [new IsCanSavePaymentMethod()]; @@ -111,14 +98,7 @@ private function processApplePay(PaymentMethodInterface $paymentMethod): Constra 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()]; return $this->validator->validate($paymentMethod, $constraintList, self::VALIDATION_GROUPS); } 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..8d3a33d2 --- /dev/null +++ b/templates/shop/hosted_fields/index.html.twig @@ -0,0 +1,66 @@ + + +
+
+ {{ 'sylius.ui.loading'|trans }} +
+
+ VISA + MC + CB +
+
+
+
+
+
+
+
+ {% 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/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..752af554 --- /dev/null +++ b/tests/PHPUnit/Action/Admin/Auth/UnifiedAuthenticationControllerTest.php @@ -0,0 +1,279 @@ +router = $this->createMock(RouterInterface::class); + $this->paymentMethodRepository = $this->createMock(RepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + // final class — cannot be mocked by PHPUnit. Its process() method is never reached by + // the scenarios covered here (they all stop before that point), so a real instance + // wired with mocked collaborators is built purely to satisfy the constructor type-hint. + $this->paymentMethodValidator = new PaymentMethodValidator( + $this->createMock(RequestStack::class), + $this->createMock(ValidatorInterface::class), + $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, + 'https://api-qa.payplug.com', + 'https://www.payplug.com', + ); + + $this->controller->setContainer(new ServiceLocator([ + 'router' => fn () => $this->router, + ])); + } + + 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); + } +} diff --git a/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php new file mode 100644 index 00000000..a072416c --- /dev/null +++ b/tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php @@ -0,0 +1,175 @@ +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); + } + + private function buildGatewayConfig(bool $isLive): GatewayConfigInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + 'live' => $isLive, + 'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'], + 'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'], + ]); + $gatewayConfig->method('getFactoryName')->willReturn('payplug'); + + return $gatewayConfig; + } +} 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/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php new file mode 100644 index 00000000..31281589 --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CaptureAliasPaymentRequestHandlerTest.php @@ -0,0 +1,371 @@ +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_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..7444c0ad --- /dev/null +++ b/tests/PHPUnit/Command/Handler/CaptureHostedPaymentRequestHandlerTest.php @@ -0,0 +1,619 @@ +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_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_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..a16cafbc --- /dev/null +++ b/tests/PHPUnit/Command/Handler/NotifyHostedPaymentRequestHandlerTest.php @@ -0,0 +1,218 @@ +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(IConfigurationRepository::class); + $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); + + $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..b8fa240f --- /dev/null +++ b/tests/PHPUnit/Command/Handler/StatusHostedPaymentRequestHandlerTest.php @@ -0,0 +1,172 @@ +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); + + $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/Controller/IpnActionTest.php b/tests/PHPUnit/Controller/IpnActionTest.php new file mode 100644 index 00000000..7b50599e --- /dev/null +++ b/tests/PHPUnit/Controller/IpnActionTest.php @@ -0,0 +1,97 @@ +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; + } + + public function testInvoke_forALegacyPayment_goesThroughTheSdk(): 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('create')->with(PayPlugGatewayFactory::FACTORY_NAME) + ->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..861aad54 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Extension/PayPlugGatewayConfigurationTypeExtensionFormSubmissionTest.php @@ -0,0 +1,391 @@ + gatewayConfig -> config), because the extended type's own inherited + * AbstractGatewayConfigurationType::buildForm() PRE_SUBMIT listener walks + * getParent()->getParent() to reach the payment method entity and its "channels" field. + */ +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); + + $gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + $gatewayConfigRepository->method('findOneBy')->willReturn(null); + + $request = new Request(); + $request->setSession(new Session(new MockArraySessionStorage())); + $requestStack = new RequestStack(); + $requestStack->push($request); + + return [ + new PayPlugGatewayConfigurationType($translator, $gatewayConfigRepository, $requestStack), + ]; + } + + 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()); + } + + /** + * PRE-3553: selecting a non-EUR channel while `integrated_payment` is selected must be + * rejected with a message specific to this feature ("...not compatible with Integrated + * Payment"), not the generic per-gateway `base_currency_not_euro` wording every other + * PayPlug-family gateway subtype still uses (Bancontact, American Express, Scalapay...). + */ + public function testSubmit_integratedPaymentModeWithNonEurChannel_isInvalidWithCurrencyIncompatibleMessage(): void + { + $form = $this->createRootForm($this->buildChannels(['USD'])); + + // clearMissing=false: "channels" isn't part of this submitted payload (only + // gatewayConfig.config is), and the default clearMissing=true would otherwise call + // submit(null) on it regardless - wiping the Collection set via createRootForm() before + // the currency-check listener ever runs, even though it's not disabled. + $form->submit([ + 'gatewayConfig' => [ + 'config' => [ + PayPlugGatewayFactory::ONE_CLICK => false, + PayPlugGatewayFactory::DEFERRED_CAPTURE => false, + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + PayPlugGatewayFactory::HF_IDENTIFIER => '', + ], + ], + ], false); + + self::assertFalse($form->isValid(), 'Form must be invalid when integrated_payment is selected but an associated channel is not EUR.'); + + // The error is added to the specific channel's own child sub-form (mirroring the real + // `channels` field being `multiple => true, expanded => true`, one child per channel), + // not directly to the "channels" form itself. + $channelErrors = $form->get('channels')->get('0')->getErrors(); + self::assertCount(1, $channelErrors); + self::assertSame( + 'payplug_sylius_payplug_plugin.form.integrated_payment_currency_incompatible', + $channelErrors[0]->getMessage(), + ); + } + + /** + * The same non-EUR channel must NOT be rejected for hosted_fields or redirected mode — only + * integrated_payment requires every associated channel to be EUR. + */ + public function testSubmit_hostedFieldsModeWithNonEurChannel_isValid(): void + { + $form = $this->createRootForm($this->buildChannels(['USD'])); + + $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', + ], + ], + ], false); + + self::assertTrue($form->isValid()); + } + + /** + * @param list $currencyCodes + * + * @return ArrayCollection + */ + private function buildChannels(array $currencyCodes): ArrayCollection + { + $channels = []; + foreach ($currencyCodes as $index => $currencyCode) { + $currency = $this->createMock(CurrencyInterface::class); + $currency->method('getCode')->willReturn($currencyCode); + + /** @var ChannelInterface&MockObject $channel */ + $channel = $this->createMock(ChannelInterface::class); + $channel->method('getCode')->willReturn('channel_' . $index); + $channel->method('getBaseCurrency')->willReturn($currency); + + $channels[] = $channel; + } + + return new ArrayCollection($channels); + } + + /** + * Builds a minimal but realistic 3-level tree: root (the PaymentMethod form, exposing + * "channels") -> gatewayConfig -> config (PayPlugGatewayConfigurationType, the type under + * test). This mirrors production nesting closely enough to exercise + * AbstractGatewayConfigurationType's inherited PRE_SUBMIT listener (which the extended type + * still carries) without it fatal-erroring on missing parents. + * + * @param ArrayCollection|null $channels Real channel data for the + * "channels" field, needed by + * tests exercising the currency + * check. Left null (an unset, + * non-Collection field) for tests + * that don't care about it. + */ + private function createRootForm(?ArrayCollection $channels = null): \Symfony\Component\Form\FormInterface + { + $paymentMethod = new class() { + public function getId(): ?int + { + // Non-null so AbstractGatewayConfigurationType::checkCreationRequirements() + // short-circuits without needing a configured gatewayConfigRepository. + return 1; + } + }; + + $root = $this->factory->createBuilder(FormType::class, $paymentMethod, ['data_class' => null]); + if (null !== $channels) { + // A bare FormType (no data_class) round-trips setData()/getData() untouched - unlike + // TextType, it has no model-to-view transformer that would choke on a Collection. It + // needs one child per channel, named by its collection key, because the production + // currency-check listener does `$formChannels->get((string) $key)->addError(...)` - + // mirroring the real `channels` field being a `multiple => true, expanded => true` + // ChoiceType, which creates one child sub-form per choice - and, critically, sets + // `error_bubbling => false` on those children (ChoiceType.php), unlike a bare + // FormType's default of bubbling errors up to its parent when compound. Without this, + // addError() on a channel's sub-form bubbles all the way to the root instead of + // staying on that sub-form - purely a test-double mismatch, not a production concern. + // NOTE: this field is NOT `disabled => true` - Form::isValid() unconditionally returns + // true for a disabled form regardless of its errors, and Form::getErrors(true) skips + // any child that isSubmitted() && isValid() when aggregating - together those two + // rules mean a disabled "channels" would make the whole root form always report valid + // no matter what error is added deep inside it. Its pre-set data survives submission + // instead via `$form->submit($data, false)` (clearMissing=false) at the call site, + // which is not disabled but also isn't reset by an absent key. + $channelsBuilder = $root->create('channels', FormType::class, [ + 'mapped' => false, + 'data_class' => null, + ]); + foreach ($channels as $key => $channel) { + $channelsBuilder->add((string) $key, FormType::class, [ + 'mapped' => false, + 'data_class' => null, + 'error_bubbling' => false, + ]); + } + $root->add($channelsBuilder); + } else { + $root->add('channels', TextType::class, ['mapped' => false]); + } + + $gatewayConfig = $root->create('gatewayConfig', FormType::class, ['mapped' => false]); + $gatewayConfig->add('config', PayPlugGatewayConfigurationType::class); + + $root->add($gatewayConfig); + + $form = $root->getForm(); + if (null !== $channels) { + // Force the root's own lazy defaultDataSet initialization (and its mapDataToForms + // cascade, which would otherwise reset the unmapped "channels" field to null the + // first time anything touches this form) to run now, BEFORE setting "channels"'s + // real data below - so our setData() call is the last word, not overwritten by it. + $form->getData(); + $form->get('channels')->setData($channels); + } + + return $form; + } +} 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/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..619aa70a --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/AbstractGatewayConfigurationTypeTest.php @@ -0,0 +1,129 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + $this->translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + $this->type = new AbstractGatewayConfigurationType( + $this->translator, + $this->gatewayConfigRepository, + $this->createMock(RequestStack::class), + ); + } + + /** + * Every PayPlug-family factory, including `payplug` itself, is limited to one PaymentMethod. + */ + public function testCanBeCreated_otherFactoryAlreadyConfigured_isRefused(): void + { + $this->gatewayConfigRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME]) + ->willReturn($this->createMock(GatewayConfigInterface::class)) + ; + + self::assertFalse($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME)); + } + + public function testCanBeCreated_otherFactoryNotYetConfigured_isAllowed(): void + { + $this->gatewayConfigRepository + ->expects(self::once()) + ->method('findOneBy') + ->with(['factoryName' => OneyGatewayFactory::FACTORY_NAME]) + ->willReturn(null) + ; + + self::assertTrue($this->canBeCreated(OneyGatewayFactory::FACTORY_NAME)); + } + + private function canBeCreated(string $factoryName): bool + { + $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'canBeCreated'); + $method->setAccessible(true); + + /** @var bool $result */ + $result = $method->invoke($this->type, $factoryName); + + return $result; + } + + /** + * 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->shouldValidateBaseCurrency([])); + self::assertTrue($this->shouldValidateBaseCurrency(['anything' => 'irrelevant'])); + } + + private function shouldValidateBaseCurrency(array $data): bool + { + $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'shouldValidateBaseCurrency'); + $method->setAccessible(true); + + /** @var bool $result */ + $result = $method->invoke($this->type, $data); + + return $result; + } + + /** + * 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->baseCurrencyViolationMessage($channel), + ); + } + + private function baseCurrencyViolationMessage(ChannelInterface $channel): string + { + $method = new \ReflectionMethod(AbstractGatewayConfigurationType::class, 'baseCurrencyViolationMessage'); + $method->setAccessible(true); + + /** @var string $result */ + $result = $method->invoke($this->type, $channel); + + return $result; + } +} diff --git a/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php new file mode 100644 index 00000000..882ac793 --- /dev/null +++ b/tests/PHPUnit/Gateway/Form/Type/PayPlugGatewayConfigurationTypeTest.php @@ -0,0 +1,90 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->translator->method('trans')->willReturnCallback(static fn (string $id) => $id); + + $this->type = new PayPlugGatewayConfigurationType( + $this->translator, + $this->createMock(RepositoryInterface::class), + $this->createMock(RequestStack::class), + ); + } + + public function testShouldValidateBaseCurrency_integratedPaymentSelected_returnsTrue(): void + { + self::assertTrue($this->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_INTEGRATED_PAYMENT, + ])); + } + + public function testShouldValidateBaseCurrency_hostedFieldsSelected_returnsFalse(): void + { + self::assertFalse($this->shouldValidateBaseCurrency([ + PayPlugGatewayFactory::DISPLAY_MODE_FIELD => PayPlugGatewayFactory::DISPLAY_MODE_HOSTED_FIELDS, + ])); + } + + 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/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..6a222db4 --- /dev/null +++ b/tests/PHPUnit/Handler/HostedFieldsWebhookNotificationHandlerTest.php @@ -0,0 +1,639 @@ +paymentRepository = $this->createMock(IPaymentRepository::class); + $this->orderStateMutator = $this->createMock(IOrderStateMutator::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $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); + $payment->method('getMethod')->willReturn($method); + + 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_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']); + } +} 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/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..1b05e29f 100644 --- a/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php +++ b/tests/PHPUnit/PaymentProcessing/RefundPaymentProcessorTest.php @@ -14,9 +14,13 @@ use PayPlug\SyliusPayPlugPlugin\Gateway\WeroGatewayFactory; use PayPlug\SyliusPayPlugPlugin\PaymentProcessing\RefundPaymentProcessor; use PayPlug\SyliusPayPlugPlugin\Repository\RefundHistoryRepositoryInterface; +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; @@ -42,6 +46,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 +61,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 +74,8 @@ protected function setUp(): void $this->refundPaymentRepository, $this->payplugRefundHistoryRepository, $this->apiClientFactory, + $this->refundCreator, + $this->lock, ); } @@ -238,6 +251,481 @@ 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); + } + + /** + * 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 // ------------------------------------------------------------------------- @@ -256,4 +744,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..d208c8f3 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; @@ -33,7 +36,7 @@ protected function setUp(): void $this->clientFactory->method('create')->willReturn($this->apiClient); - $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory); + $this->provider = new SupportedMethodsProvider($this->currencyContext, $this->clientFactory, new AccountAmountRangeResolver(), new NullLogger()); } // ------------------------------------------------------------------------- @@ -67,6 +70,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 +86,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 +316,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 +334,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 +352,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 +370,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 +450,128 @@ 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); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -374,10 +587,15 @@ private function buildAccount(int $minAmount, int $maxAmount): array ]; } - private function buildPaymentMethod(string $factoryName): PaymentMethodInterface + /** + * @param array $config Persisted gateway config; defaults to empty, which is + * neither integrated_payment nor hosted_fields. + */ + private function buildPaymentMethod(string $factoryName, array $config = []): PaymentMethodInterface { $gatewayConfig = $this->createMock(GatewayConfigInterface::class); $gatewayConfig->method('getFactoryName')->willReturn($factoryName); + $gatewayConfig->method('getConfig')->willReturn($config); $method = $this->createMock(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); 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/Twig/PayPlugExtensionTest.php b/tests/PHPUnit/Twig/PayPlugExtensionTest.php new file mode 100644 index 00000000..ebf560e8 --- /dev/null +++ b/tests/PHPUnit/Twig/PayPlugExtensionTest.php @@ -0,0 +1,50 @@ +canSaveCardChecker = $this->createMock(CanSaveCardCheckerInterface::class); + $this->apiClientFactory = $this->createMock(PayPlugApiClientFactoryInterface::class); + + $this->extension = new PayPlugExtension($this->canSaveCardChecker, $this->apiClientFactory); + } + + 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/CardDataFromPaymentMethodExtractorTest.php b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php new file mode 100644 index 00000000..52568e78 --- /dev/null +++ b/tests/PHPUnit/Upc/CardDataFromPaymentMethodExtractorTest.php @@ -0,0 +1,124 @@ + [ + '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 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..533dc551 --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureContextBuilderTest.php @@ -0,0 +1,296 @@ +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_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..801d685b --- /dev/null +++ b/tests/PHPUnit/Upc/PaymentCaptureOutcomeApplierTest.php @@ -0,0 +1,194 @@ +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_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); + } +} diff --git a/tests/PHPUnit/Upc/PayplugCardPersisterTest.php b/tests/PHPUnit/Upc/PayplugCardPersisterTest.php new file mode 100644 index 00000000..29699845 --- /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' => 'CB', + '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..f905d4aa --- /dev/null +++ b/tests/PHPUnit/Upc/SyliusUpcConfigurationRepositoryTest.php @@ -0,0 +1,106 @@ +gatewayConfigRepository = $this->createMock(RepositoryInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->configurationRepository = new SyliusUpcConfigurationRepository($this->gatewayConfigRepository, $this->entityManager); + } + + private function gatewayConfigWith(array $config): GatewayConfigInterface&MockObject + { + $gatewayConfig = $this->createMock(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); + $this->gatewayConfigRepository->method('findOneBy') + ->with(['factoryName' => PayPlugGatewayFactory::FACTORY_NAME]) + ->willReturn($gatewayConfig); + + return $gatewayConfig; + } + + public function testGetClientId_whenLive_readsFromLiveClient(): void + { + $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + + self::assertSame('live_id', $this->configurationRepository->getClientId()); + } + + public function testGetClientId_whenNotLive_readsFromTestClient(): void + { + $this->gatewayConfigWith(['live' => false, 'test_client' => ['client_id' => 'test_id', 'client_secret' => 'test_secret']]); + + self::assertSame('test_id', $this->configurationRepository->getClientId()); + } + + public function testGetClientSecret_whenLive_readsFromLiveClient(): void + { + $this->gatewayConfigWith(['live' => true, 'live_client' => ['client_id' => 'live_id', 'client_secret' => 'live_secret']]); + + self::assertSame('live_secret', $this->configurationRepository->getClientSecret()); + } + + public function testGetClientId_whenNoClientConfigStored_returnsEmptyString(): void + { + $this->gatewayConfigWith(['live' => false]); + + self::assertSame('', $this->configurationRepository->getClientId()); + } + + public function testGetPublicKeyId_readsHfIdentifier(): void + { + $this->gatewayConfigWith([PayPlugGatewayFactory::HF_IDENTIFIER => 'hf_ident_123']); + + self::assertSame('hf_ident_123', $this->configurationRepository->getPublicKeyId()); + } + + public function testGetPublicKeyValue_returnsEmptyString(): void + { + $this->gatewayConfigWith([]); + + self::assertSame('', $this->configurationRepository->getPublicKeyValue()); + } + + public function testGet_readsArbitraryKeyFromConfig(): void + { + $this->gatewayConfigWith(['payplug_webhook_authorization_header' => 'Bearer shared-secret']); + + self::assertSame('Bearer shared-secret', $this->configurationRepository->get('payplug_webhook_authorization_header')); + } + + public function testGet_whenKeyMissing_returnsNull(): void + { + $this->gatewayConfigWith([]); + + self::assertNull($this->configurationRepository->get('missing_key')); + } + + public function testSet_mergesTheKeyIntoConfigAndFlushes(): void + { + $gatewayConfig = $this->gatewayConfigWith(['existing' => 'value']); + $gatewayConfig->expects(self::once())->method('setConfig') + ->with(['existing' => 'value', 'new_key' => 'new_value']); + $this->entityManager->expects(self::once())->method('flush'); + + $this->configurationRepository->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/UnifiedApiOperationStatusFetcherTest.php b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php new file mode 100644 index 00000000..9db28d0f --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiOperationStatusFetcherTest.php @@ -0,0 +1,94 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository->method('getClientId')->willReturn('client_abc'); + $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $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', + ); + } + + 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'); + + 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'); + } + + 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'); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php new file mode 100644 index 00000000..4636d692 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiPaymentCreatorTest.php @@ -0,0 +1,100 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository->method('getClientId')->willReturn('client_abc'); + $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $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'); + } + + 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()); + + 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()); + + 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()); + } +} diff --git a/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php new file mode 100644 index 00000000..2c9754d0 --- /dev/null +++ b/tests/PHPUnit/Upc/UnifiedApiRefundCreatorTest.php @@ -0,0 +1,195 @@ +unifiedApiHttpClient = $this->createMock(IUnifiedApiHttpClient::class); + $this->oauthHttpClient = $this->createMock(IOAuthHttpClient::class); + $this->tokenCache = $this->createMock(ITokenCache::class); + $this->configurationRepository = $this->createMock(IConfigurationRepository::class); + $this->configurationRepository->method('getClientId')->willReturn('client_abc'); + $this->configurationRepository->method('getClientSecret')->willReturn('secret_xyz'); + + $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', + ); + + $this->tokenCache->method('get')->willReturn('cached-jwt'); + } + + 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..8ce05c0a 100644 --- a/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php +++ b/tests/PHPUnit/Validator/PaymentMethodValidatorTest.php @@ -8,6 +8,10 @@ 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\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 +141,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,7 +162,6 @@ 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); return new ConstraintViolationList(); @@ -173,12 +177,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 { @@ -208,6 +213,111 @@ public function testProcess_payplugFactory_allFlagsEnabled_validatesWithAllConst $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(1, $constraints); + 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(2, $constraints); + 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(2, $constraints); + self::assertInstanceOf(IsCanSavePaymentMethod::class, $constraints[0]); + self::assertInstanceOf(IsScalapayAmountRangeValid::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); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/translations/flashes.en.yml b/translations/flashes.en.yml index 1faaa11a..7d4de015 100644 --- a/translations/flashes.en.yml +++ b/translations/flashes.en.yml @@ -3,6 +3,7 @@ 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: diff --git a/translations/flashes.fr.yml b/translations/flashes.fr.yml index b05b5110..cca3eac2 100644 --- a/translations/flashes.fr.yml +++ b/translations/flashes.fr.yml @@ -3,6 +3,7 @@ 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: diff --git a/translations/flashes.it.yml b/translations/flashes.it.yml index 10cd5113..49355425 100644 --- a/translations/flashes.it.yml +++ b/translations/flashes.it.yml @@ -3,6 +3,7 @@ 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: diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 9ffa6acb..a486cfb3 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -93,6 +93,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 +111,11 @@ 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' deferred_capture: process_order_info: | You will be charged when your order is processed. @@ -117,6 +126,8 @@ 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' form: oney_error: Some missing information is required to pay using Oney by Payplug complete_info: @@ -137,6 +148,9 @@ payplug_sylius_payplug_plugin: 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..4c17f7ad 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -111,6 +111,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 +130,11 @@ 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' deferred_capture: process_order_info: | Vous serez prélevé(é) lors du traitement de votre commande. @@ -137,6 +146,8 @@ 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)' form: oney_error: Il y a des informations manquantes pour pouvoir payer en utilisant Oney by Payplug complete_info: @@ -158,6 +169,9 @@ payplug_sylius_payplug_plugin: 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..16e60374 100644 --- a/translations/messages.it.yml +++ b/translations/messages.it.yml @@ -93,6 +93,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 +111,11 @@ 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' deferred_capture: process_order_info: | L'addebito avverrà al momento dell'elaborazione dell'ordine. @@ -117,6 +126,8 @@ 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)' form: oney_error: Mancano alcune informazioni per poter pagare con “Oney by Payplug” complete_info: @@ -137,6 +148,9 @@ payplug_sylius_payplug_plugin: 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.