├── .env.dev ├── etc └── build │ ├── .gitkeep │ └── .gitignore ├── translations ├── .gitignore ├── messages.fr.yml ├── messages.nl.yml ├── validators.en.yml ├── validators.de.yml ├── messages.en.yml └── messages.de.yml ├── assets ├── admin │ └── entrypoint.js ├── shop │ ├── entrypoint.js │ └── js │ │ └── greetings.js ├── styles │ └── app.css ├── app.js ├── bootstrap.js ├── controllers │ └── hello_controller.js ├── controllers.json └── icons │ └── symfony.svg ├── config ├── services │ └── .gitkeep ├── packages │ ├── workflow.yaml │ ├── mailer.yaml │ ├── uid.yaml │ ├── twig.yaml │ ├── translation.yaml │ ├── lexik_jwt_authentication.yaml │ ├── twig_component.yaml │ ├── api_platform.yaml │ ├── debug.yaml │ ├── doctrine_migrations.yaml │ ├── flysystem.yaml │ ├── routing.yaml │ ├── validator.yaml │ ├── web_profiler.yaml │ ├── http_discovery.yaml │ ├── cache.yaml │ ├── framework.yaml │ ├── messenger.yaml │ ├── doctrine.yaml │ ├── webpack_encore.yaml │ ├── security.yaml │ └── monolog.yaml ├── routes │ ├── api_platform.yaml │ ├── security.yaml │ ├── ux_autocomplete.yaml │ ├── framework.yaml │ ├── ux_live_component.yaml │ └── web_profiler.yaml ├── routes.yml ├── preload.php ├── app │ └── twig_hooks │ │ ├── shop.yaml │ │ └── admin.yaml ├── config.yaml ├── doctrine │ ├── ProductVariant.orm.xml │ └── TierPrice.orm.xml ├── validation │ └── validation.xml ├── bundles.php └── services.php ├── tests ├── Application │ ├── templates │ │ └── .gitignore │ ├── src │ │ └── Entity │ │ │ └── .gitignore │ ├── translations │ │ └── .gitignore │ ├── assets │ │ ├── admin │ │ │ └── entrypoint.js │ │ └── shop │ │ │ └── entrypoint.js │ ├── config │ │ ├── api_platform │ │ │ └── .gitignore │ │ ├── secrets │ │ │ ├── dev │ │ │ │ └── .gitignore │ │ │ ├── prod │ │ │ │ └── .gitignore │ │ │ ├── test │ │ │ │ └── .gitignore │ │ │ └── test_cached │ │ │ │ └── .gitignore │ │ ├── serialization │ │ │ └── .gitignore │ │ ├── packages │ │ │ ├── workflow.yaml │ │ │ ├── test_cached │ │ │ │ ├── twig.yaml │ │ │ │ ├── sylius_channel.yaml │ │ │ │ ├── sylius_theme.yaml │ │ │ │ ├── framework.yaml │ │ │ │ ├── monolog.yaml │ │ │ │ └── doctrine.yaml │ │ │ ├── dev │ │ │ │ ├── framework.yaml │ │ │ │ ├── routing.yaml │ │ │ │ ├── web_profiler.yaml │ │ │ │ └── monolog.yaml │ │ │ ├── mailer.yaml │ │ │ ├── routing.yaml │ │ │ ├── test │ │ │ │ ├── sylius_theme.yaml │ │ │ │ ├── web_profiler.yaml │ │ │ │ ├── monolog.yaml │ │ │ │ └── framework.yaml │ │ │ ├── validator.yaml │ │ │ ├── doctrine_migrations.yaml │ │ │ ├── lexik_jwt_authentication.yaml │ │ │ ├── liip_imagine.yaml │ │ │ ├── translation.yaml │ │ │ ├── framework.yaml │ │ │ ├── prod │ │ │ │ ├── monolog.yaml │ │ │ │ └── doctrine.yaml │ │ │ ├── twig.yaml │ │ │ ├── staging │ │ │ │ └── monolog.yaml │ │ │ ├── stof_doctrine_extensions.yaml │ │ │ ├── brille24_sylius_tierprice_plugin.yaml │ │ │ ├── sylius_state_machine_abstraction.yaml │ │ │ ├── webpack_encore.yaml │ │ │ ├── api_platform.yaml │ │ │ ├── doctrine.yaml │ │ │ ├── assets.yaml │ │ │ ├── http_discovery.yaml │ │ │ ├── _sylius.yaml │ │ │ └── security.yaml │ │ ├── services_test_cached.yaml │ │ ├── routes │ │ │ ├── liip_imagine.yaml │ │ │ ├── sylius_api.yaml │ │ │ ├── sylius_admin.yaml │ │ │ ├── dev │ │ │ │ └── web_profiler.yaml │ │ │ └── sylius_shop.yaml │ │ ├── routes.yaml │ │ ├── services.yaml │ │ ├── services_test.yaml │ │ ├── jwt │ │ │ ├── public.pem │ │ │ └── private.pem │ │ ├── bootstrap.php │ │ └── bundles.php │ ├── public │ │ ├── favicon.ico │ │ ├── robots.txt │ │ ├── .htaccess │ │ └── index.php │ ├── composer.json │ ├── Kernel.php │ ├── .env.test │ ├── .eslintrc.js │ ├── .gitignore │ ├── package.json │ ├── bin │ │ └── console │ ├── webpack.config.js │ └── .env ├── bootstrap.php └── Behat │ ├── Resources │ ├── services.xml │ └── suites.yml │ └── Context │ └── TierPriceContext.php ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── build.yml ├── images ├── logo.png ├── Backend.png └── Front-End.png ├── .docker ├── php │ └── php.ini └── nginx │ └── nginx.conf ├── docs └── Tierprice Diagram.png ├── phpspec.yml.dist ├── .env.test ├── templates ├── Admin │ └── product_variant │ │ └── form │ │ ├── sections │ │ ├── tierprice │ │ │ ├── errors.html.twig │ │ │ └── form.html.twig │ │ └── tierprice.html.twig │ │ └── side_navigation │ │ └── tierprice.html.twig └── Shop │ └── Product │ └── Show │ └── _tier_price_promo.html.twig ├── UPGRADE.md ├── compose.override.yaml ├── src ├── Form │ ├── Components │ │ └── ProductVariantFormComponent.php │ ├── Extension │ │ └── ProductVariantTypeExtension.php │ └── TierPriceType.php ├── Entity │ ├── ProductVariantInterface.php │ ├── ProductVariant.php │ ├── TierPriceInterface.php │ └── TierPrice.php ├── Brille24SyliusTierPricePlugin.php ├── DependencyInjection │ ├── Configuration.php │ └── Brille24SyliusTierPriceExtension.php ├── Services │ ├── TierPriceFinderInterface.php │ ├── TierPriceFinder.php │ ├── OrderPricesRecalculator.php │ └── ProductVariantPriceCalculator.php ├── Factory │ ├── TierPriceFactoryInterface.php │ ├── TierPriceFactory.php │ └── TierPriceExampleFactory.php ├── Validator │ ├── TierPriceUniqueConstraint.php │ └── TierPriceUniqueValidator.php ├── Repository │ ├── TierPriceRepositoryInterface.php │ └── TierPriceRepository.php ├── Traits │ ├── TierPriceableInterface.php │ └── TierPriceableTrait.php ├── Fixtures │ └── TierPriceFixture.php └── Tests │ ├── Factory │ ├── TierPriceFactoryTest.php │ └── TierPriceExampleFactoryTest.php │ ├── Services │ ├── TierPriceFinderTest.php │ ├── ProductVariantPriceCalculatorTest.php │ └── OrderPricesRecalculatorTest.php │ └── Entity │ └── ProductVariantTest.php ├── bin ├── console └── create_node_symlink.php ├── supervisord.log ├── phpstan.neon ├── Makefile ├── features └── cart │ └── order_price.feature ├── .gitignore ├── phpunit.xml.dist ├── UPGRADE-2.md ├── LICENSE ├── ecs.php ├── .editorconfig ├── UPGRADE-1.3.md ├── docker-compose.yml ├── .env ├── behat.yml.dist ├── webpack.config.js ├── composer.json └── README.md /.env.dev: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /etc/build/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /etc/build/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/admin/entrypoint.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/templates/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Sylius/core-team 2 | -------------------------------------------------------------------------------- /tests/Application/src/Entity/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/assets/admin/entrypoint.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/config/api_platform/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/config/secrets/dev/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/config/secrets/prod/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/config/secrets/test/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Application/config/serialization/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/shop/entrypoint.js: -------------------------------------------------------------------------------- 1 | import './js/greetings'; 2 | -------------------------------------------------------------------------------- /tests/Application/config/secrets/test_cached/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/packages/workflow.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | workflows: null 3 | -------------------------------------------------------------------------------- /assets/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: lightgray; 3 | } 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/workflow.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | workflows: ~ 3 | -------------------------------------------------------------------------------- /tests/Application/assets/shop/entrypoint.js: -------------------------------------------------------------------------------- 1 | import '../../../../assets/shop/entrypoint'; 2 | -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brille24/SyliusTierpricePlugin/HEAD/images/logo.png -------------------------------------------------------------------------------- /tests/Application/config/packages/test_cached/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | strict_variables: true 3 | -------------------------------------------------------------------------------- /images/Backend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brille24/SyliusTierpricePlugin/HEAD/images/Backend.png -------------------------------------------------------------------------------- /.docker/php/php.ini: -------------------------------------------------------------------------------- 1 | [PHP] 2 | memory_limit=512M 3 | 4 | [date] 5 | date.timezone=${PHP_DATE_TIMEZONE} 6 | -------------------------------------------------------------------------------- /images/Front-End.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brille24/SyliusTierpricePlugin/HEAD/images/Front-End.png -------------------------------------------------------------------------------- /tests/Application/config/packages/test_cached/sylius_channel.yaml: -------------------------------------------------------------------------------- 1 | sylius_channel: 2 | debug: true 3 | -------------------------------------------------------------------------------- /tests/Application/config/services_test_cached.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "services_test.yaml" } 3 | -------------------------------------------------------------------------------- /translations/messages.fr.yml: -------------------------------------------------------------------------------- 1 | brille24_tier_price: 2 | ui: 3 | tier_prices: 'Prix de groupe' 4 | -------------------------------------------------------------------------------- /translations/messages.nl.yml: -------------------------------------------------------------------------------- 1 | brille24_tier_price: 2 | ui: 3 | tier_prices: Staffelprijzen 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/dev/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | profiler: { only_exceptions: false } 3 | -------------------------------------------------------------------------------- /tests/Application/config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: ~ 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/test/sylius_theme.yaml: -------------------------------------------------------------------------------- 1 | sylius_theme: 2 | sources: 3 | test: ~ 4 | -------------------------------------------------------------------------------- /config/routes/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | resource: . 3 | type: api_platform 4 | prefix: /api 5 | -------------------------------------------------------------------------------- /config/routes/security.yaml: -------------------------------------------------------------------------------- 1 | _security_logout: 2 | resource: security.route_loader.logout 3 | type: service 4 | -------------------------------------------------------------------------------- /docs/Tierprice Diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brille24/SyliusTierpricePlugin/HEAD/docs/Tierprice Diagram.png -------------------------------------------------------------------------------- /tests/Application/config/packages/test_cached/sylius_theme.yaml: -------------------------------------------------------------------------------- 1 | sylius_theme: 2 | sources: 3 | test: ~ 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/dev/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: true 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/test_cached/framework.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: ../test/framework.yaml } 3 | -------------------------------------------------------------------------------- /config/packages/uid.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | uid: 3 | default_uuid_version: 7 4 | time_based_uuid_version: 7 5 | -------------------------------------------------------------------------------- /tests/Application/config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | -------------------------------------------------------------------------------- /tests/Application/config/routes/liip_imagine.yaml: -------------------------------------------------------------------------------- 1 | _liip_imagine: 2 | resource: "@LiipImagineBundle/Resources/config/routing.yaml" 3 | -------------------------------------------------------------------------------- /tests/Application/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brille24/SyliusTierpricePlugin/HEAD/tests/Application/public/favicon.ico -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | file_name_pattern: '*.twig' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /tests/Application/config/routes.yaml: -------------------------------------------------------------------------------- 1 | brille24_sylius_tierprice_plugin: 2 | resource: "@Brille24SyliusTierPricePlugin/config/routes.yml" 3 | -------------------------------------------------------------------------------- /config/routes/ux_autocomplete.yaml: -------------------------------------------------------------------------------- 1 | ux_autocomplete: 2 | resource: '@AutocompleteBundle/config/routes.php' 3 | prefix: '/autocomplete' 4 | -------------------------------------------------------------------------------- /phpspec.yml.dist: -------------------------------------------------------------------------------- 1 | suites: 2 | main: 3 | namespace: Brille24\SyliusTierPricePlugin 4 | psr4_prefix: Brille24\SyliusTierPricePlugin 5 | -------------------------------------------------------------------------------- /translations/validators.en.yml: -------------------------------------------------------------------------------- 1 | brille24_tier_price: 2 | form: 3 | validation: 4 | not_unique: Tierprice for this quantity already exists. 5 | -------------------------------------------------------------------------------- /tests/Application/public/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | # www.google.com/support/webmasters/bin/answer.py?hl=en&answer=156449 3 | 4 | User-agent: * 5 | -------------------------------------------------------------------------------- /translations/validators.de.yml: -------------------------------------------------------------------------------- 1 | brille24_tier_price: 2 | form: 3 | validation: 4 | not_unique: Staffelpreis für diese Menge existiert schon. 5 | -------------------------------------------------------------------------------- /config/routes.yml: -------------------------------------------------------------------------------- 1 | brille24_tierprice: 2 | prefix: /api/product-variants/ 3 | resource: | 4 | alias: brille24.tierprice 5 | type: sylius.resource_api 6 | -------------------------------------------------------------------------------- /config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /tests/Application/config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | storage: 3 | table_storage: 4 | table_name: sylius_migrations 5 | -------------------------------------------------------------------------------- /tests/Application/config/routes/sylius_api.yaml: -------------------------------------------------------------------------------- 1 | sylius_api: 2 | resource: "@SyliusApiBundle/Resources/config/routing.yml" 3 | prefix: "%sylius.security.api_route%" 4 | -------------------------------------------------------------------------------- /assets/shop/js/greetings.js: -------------------------------------------------------------------------------- 1 | setTimeout(function () { 2 | document.getElementById('greeting').innerHTML = document.getElementById('greeting').dataset.greeting; 3 | }, 1000); 4 | -------------------------------------------------------------------------------- /tests/Application/config/routes/sylius_admin.yaml: -------------------------------------------------------------------------------- 1 | sylius_admin: 2 | resource: "@SyliusAdminBundle/Resources/config/routing.yml" 3 | prefix: '/%sylius_admin.path_name%' 4 | -------------------------------------------------------------------------------- /tests/Application/config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | providers: 8 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | 5 | {{ 'brille24_tier_price.ui.product_not_enabled_in_any_channel'|trans }} 6 | 7 | {% endif %} 8 | 9 | -------------------------------------------------------------------------------- /tests/Application/config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | nested: 8 | type: stream 9 | path: "%kernel.logs_dir%/%kernel.environment%.log" 10 | level: debug 11 | -------------------------------------------------------------------------------- /tests/Application/config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: ~ 3 | session: 4 | storage_factory_id: session.storage.factory.mock_file 5 | 6 | mailer: 7 | dsn: '%env(MAILER_DSN)%' 8 | cache: 9 | pools: 10 | test.mailer_pool: 11 | adapter: cache.adapter.filesystem 12 | -------------------------------------------------------------------------------- /tests/Application/config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | paths: ['%kernel.project_dir%/templates'] 3 | debug: '%kernel.debug%' 4 | strict_variables: '%kernel.debug%' 5 | 6 | services: 7 | _defaults: 8 | public: false 9 | autowire: true 10 | autoconfigure: true 11 | 12 | Twig\Extra\Intl\IntlExtension: ~ 13 | -------------------------------------------------------------------------------- /tests/Application/config/packages/staging/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | nested: 8 | type: stream 9 | path: "%kernel.logs_dir%/%kernel.environment%.log" 10 | level: debug 11 | -------------------------------------------------------------------------------- /tests/Application/Kernel.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /tests/Application/config/packages/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | webpack_encore: 2 | output_path: '%kernel.project_dir%/public/build/default' 3 | builds: 4 | shop: '%kernel.project_dir%/public/build/shop' 5 | admin: '%kernel.project_dir%/public/build/admin' 6 | app.admin: '%kernel.project_dir%/public/build/app/admin' 7 | app.shop: '%kernel.project_dir%/public/build/app/shop' 8 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /tests/Application/config/packages/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | mapping: 3 | paths: 4 | - '%kernel.project_dir%/../../vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Resources/config/api_platform' 5 | - '%kernel.project_dir%/config/api_platform' 6 | - '%kernel.project_dir%/src/Entity' 7 | patch_formats: 8 | json: ['application/merge-patch+json'] 9 | swagger: 10 | versions: [3] 11 | -------------------------------------------------------------------------------- /compose.override.yaml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | ###> symfony/mailer ### 4 | mailer: 5 | image: axllent/mailpit 6 | ports: 7 | - "1025" 8 | - "8025" 9 | environment: 10 | MP_SMTP_AUTH_ACCEPT_ANY: 1 11 | MP_SMTP_AUTH_ALLOW_INSECURE: 1 12 | ###< symfony/mailer ### 13 | 14 | ###> doctrine/doctrine-bundle ### 15 | database: 16 | ports: 17 | - "5432" 18 | ###< doctrine/doctrine-bundle ### 19 | -------------------------------------------------------------------------------- /tests/Application/config/services_test.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "../../../vendor/sylius/sylius/src/Sylius/Behat/Resources/config/services.xml" } 3 | - { resource: "../../Behat/Resources/services.xml" } 4 | 5 | # workaround needed for strange "test.client.history" problem 6 | # see https://github.com/FriendsOfBehat/SymfonyExtension/issues/88 7 | services: 8 | Symfony\Component\BrowserKit\AbstractBrowser: '@test.client' 9 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | 10 | when@test: 11 | framework: 12 | validation: 13 | not_compromised_password: false 14 | -------------------------------------------------------------------------------- /config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /assets/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { startStimulusApp } from '@symfony/stimulus-bridge'; 2 | 3 | // Registers Stimulus controllers from controllers.json and in the controllers/ directory 4 | export const app = startStimulusApp(require.context( 5 | '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', 6 | true, 7 | /\.[jt]sx?$/ 8 | )); 9 | // register any custom, 3rd party controllers here 10 | // app.register('some_controller_name', SomeImportedController); 11 | -------------------------------------------------------------------------------- /templates/Admin/product_variant/form/side_navigation/tierprice.html.twig: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "app/twig_hooks/**/*.yaml" } 3 | 4 | # New resources 5 | sylius_resource: 6 | resources: 7 | brille24.tierprice: 8 | classes: 9 | model: Brille24\SyliusTierPricePlugin\Entity\TierPrice 10 | form: Brille24\SyliusTierPricePlugin\Form\TierPriceType 11 | repository: Brille24\SyliusTierPricePlugin\Repository\TierPriceRepository 12 | # factory: Brille24\SyliusTierPricePlugin\Factory\TierPriceFactory 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/Application/config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | # Adds a fallback DATABASE_URL if the env var is not set. 3 | # This allows you to run cache:warmup even if your 4 | # environment variables are not available yet. 5 | # You should not need to change this value. 6 | env(DATABASE_URL): '' 7 | 8 | doctrine: 9 | dbal: 10 | driver: 'pdo_mysql' 11 | server_version: '5.7' 12 | charset: UTF8 13 | 14 | url: '%env(resolve:DATABASE_URL)%' 15 | -------------------------------------------------------------------------------- /tests/Behat/Resources/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/Application/config/packages/assets.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | assets: 3 | packages: 4 | shop: 5 | json_manifest_path: '%kernel.project_dir%/public/build/shop/manifest.json' 6 | admin: 7 | json_manifest_path: '%kernel.project_dir%/public/build/admin/manifest.json' 8 | app.admin: 9 | json_manifest_path: '%kernel.project_dir%/public/build/app/admin/manifest.json' 10 | app.shop: 11 | json_manifest_path: '%kernel.project_dir%/public/build/app/shop/manifest.json' 12 | -------------------------------------------------------------------------------- /assets/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | 3 | /* 4 | * This is an example Stimulus controller! 5 | * 6 | * Any element with a data-controller="hello" attribute will cause 7 | * this controller to be executed. The name "hello" comes from the filename: 8 | * hello_controller.js -> "hello" 9 | * 10 | * Delete this file or adapt it for your use! 11 | */ 12 | export default class extends Controller { 13 | connect() { 14 | this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Application/config/packages/test_cached/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | entity_managers: 4 | default: 5 | result_cache_driver: 6 | type: memcached 7 | host: localhost 8 | port: 11211 9 | query_cache_driver: 10 | type: memcached 11 | host: localhost 12 | port: 11211 13 | metadata_cache_driver: 14 | type: memcached 15 | host: localhost 16 | port: 11211 17 | -------------------------------------------------------------------------------- /config/packages/http_discovery.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' 3 | Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' 4 | Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' 5 | Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' 6 | Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' 7 | Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' 8 | 9 | http_discovery.psr17_factory: 10 | class: Http\Discovery\Psr17Factory 11 | -------------------------------------------------------------------------------- /tests/Application/.env.test: -------------------------------------------------------------------------------- 1 | APP_SECRET='ch4mb3r0f5ecr3ts' 2 | 3 | KERNEL_CLASS='Tests\Brille24\SyliusTierPricePlugin\Application\Kernel' 4 | 5 | ###> symfony/messenger ### 6 | # Sync transport turned for testing env for the ease of testing 7 | SYLIUS_MESSENGER_TRANSPORT_MAIN_DSN=sync:// 8 | SYLIUS_MESSENGER_TRANSPORT_MAIN_FAILED_DSN=sync:// 9 | SYLIUS_MESSENGER_TRANSPORT_CATALOG_PROMOTION_REMOVAL_DSN=sync:// 10 | SYLIUS_MESSENGER_TRANSPORT_CATALOG_PROMOTION_REMOVAL_FAILED_DSN=sync:// 11 | SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_DSN=sync:// 12 | SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=sync:// 13 | ###< symfony/messenger ### 14 | -------------------------------------------------------------------------------- /tests/Application/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | env: { 4 | node: true, 5 | }, 6 | rules: { 7 | 'object-shorthand': ['error', 'always', { 8 | avoidQuotes: true, 9 | avoidExplicitReturnArrows: true, 10 | }], 11 | 'function-paren-newline': ['error', 'consistent'], 12 | 'max-len': ['warn', 120, 2, { 13 | ignoreUrls: true, 14 | ignoreComments: false, 15 | ignoreRegExpLiterals: true, 16 | ignoreStrings: true, 17 | ignoreTemplateLiterals: true, 18 | }], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /tests/Application/config/packages/http_discovery.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' 3 | Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' 4 | Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' 5 | Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' 6 | Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' 7 | Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' 8 | 9 | http_discovery.psr17_factory: 10 | class: Http\Discovery\Psr17Factory 11 | -------------------------------------------------------------------------------- /tests/Application/config/packages/_sylius.yaml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "@SyliusCoreBundle/Resources/config/app/config.yml" } 3 | - { resource: "@SyliusPayumBundle/Resources/config/app/config.yaml" } 4 | - { resource: "@SyliusAdminBundle/Resources/config/app/config.yml" } 5 | - { resource: "@SyliusShopBundle/Resources/config/app/config.yml" } 6 | - { resource: "@SyliusApiBundle/Resources/config/app/config.yaml" } 7 | 8 | parameters: 9 | sylius_core.public_dir: '%kernel.project_dir%/public' 10 | 11 | sylius_shop: 12 | product_grid: 13 | include_all_descendants: true 14 | 15 | sylius_api: 16 | enabled: true 17 | -------------------------------------------------------------------------------- /src/Form/Components/ProductVariantFormComponent.php: -------------------------------------------------------------------------------- 1 | symfony/framework-bundle ### 15 | /.env.*.local 16 | /.env.local 17 | /.env.local.php 18 | /public/bundles 19 | /var/ 20 | /vendor/ 21 | ###< symfony/framework-bundle ### 22 | 23 | ###> symfony/web-server-bundle ### 24 | /.web-server-pid 25 | ###< symfony/web-server-bundle ### 26 | 27 | ###> lexik/jwt-authentication-bundle ### 28 | /config/jwt/*.pem 29 | !/config/jwt/*-test.pem 30 | ###< lexik/jwt-authentication-bundle ### 31 | -------------------------------------------------------------------------------- /src/Entity/ProductVariantInterface.php: -------------------------------------------------------------------------------- 1 | 4 | RewriteEngine On 5 | 6 | RewriteCond %{HTTP:Authorization} ^(.*) 7 | RewriteRule .* - [e=HTTP_AUTHORIZATION:%1] 8 | 9 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ 10 | RewriteRule ^(.*) - [E=BASE:%1] 11 | 12 | RewriteCond %{ENV:REDIRECT_STATUS} ^$ 13 | RewriteRule ^index\.php(/(.*)|$) %{ENV:BASE}/$2 [R=301,L] 14 | 15 | RewriteCond %{REQUEST_FILENAME} -f 16 | RewriteRule .? - [L] 17 | 18 | RewriteRule .? %{ENV:BASE}/index.php [L] 19 | 20 | 21 | 22 | 23 | RedirectMatch 302 ^/$ /index.php/ 24 | 25 | 26 | -------------------------------------------------------------------------------- /templates/Admin/product_variant/form/sections/tierprice.html.twig: -------------------------------------------------------------------------------- 1 | {% set form = hookable_metadata.context.form %} 2 | {% set product_variant = hookable_metadata.context.resource %} 3 | 4 |
5 |
6 |
7 |

8 | {{ 'brille24_tier_price.ui.tier_prices'|trans }} 9 |

10 |
11 |
12 | {% hook 'tierprice' with { form: form, channels: product_variant.product.channels } %} 13 |
14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /supervisord.log: -------------------------------------------------------------------------------- 1 | 2025-01-16 16:13:21,115 INFO Set uid to user 0 succeeded 2 | 2025-01-16 16:13:21,117 INFO supervisord started with pid 1 3 | 2025-01-16 16:13:22,123 INFO spawned: 'nginx' with pid 7 4 | 2025-01-16 16:13:22,129 INFO spawned: 'php-fpm' with pid 8 5 | 2025-01-16 16:13:23,166 INFO success: nginx entered RUNNING state, process has stayed up for > than 1 seconds (startsecs) 6 | 2025-01-16 16:13:23,167 INFO success: php-fpm entered RUNNING state, process has stayed up for > than 1 seconds (startsecs) 7 | 2025-01-16 16:16:53,408 WARN received SIGTERM indicating exit request 8 | 2025-01-16 16:16:53,409 INFO waiting for nginx, php-fpm to die 9 | 2025-01-16 16:16:54,411 INFO stopped: php-fpm (exit status 0) 10 | 2025-01-16 16:16:54,494 INFO stopped: nginx (exit status 0) 11 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | reportUnmatchedIgnoredErrors: false 4 | paths: 5 | - src 6 | - tests/Behat 7 | 8 | excludePaths: 9 | # Makes PHPStan crash 10 | - 'src/DependencyInjection/Configuration.php' 11 | 12 | # Test dependencies 13 | - 'tests/Application/app/**.php' 14 | - 'tests/Application/src/**.php' 15 | 16 | ignoreErrors: 17 | - '/Parameter #1 \$configuration of method Symfony\\Component\\DependencyInjection\\Extension\\Extension::processConfiguration\(\) expects Symfony\\Component\\Config\\Definition\\ConfigurationInterface, Symfony\\Component\\Config\\Definition\\ConfigurationInterface\|null given\./' 18 | - 19 | identifier: missingType.iterableValue 20 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /tests/Application/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "UNLICENSED", 3 | "scripts": { 4 | "build": "encore dev", 5 | "build:prod": "encore production", 6 | "watch": "encore dev --watch" 7 | }, 8 | "dependencies": { 9 | "@sylius-ui/admin": "file:../../vendor/sylius/sylius/src/Sylius/Bundle/AdminBundle", 10 | "@sylius-ui/shop": "file:../../vendor/sylius/sylius/src/Sylius/Bundle/ShopBundle", 11 | "@symfony/ux-autocomplete": "file:../../vendor/symfony/ux-autocomplete/assets", 12 | "@symfony/ux-live-component": "file:../../vendor/symfony/ux-live-component/assets" 13 | }, 14 | "devDependencies": { 15 | "@hotwired/stimulus": "^3.0.0", 16 | "@symfony/stimulus-bridge": "^3.2.0", 17 | "@symfony/webpack-encore": "^5.0.1", 18 | "tom-select": "^2.2.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Entity/ProductVariant.php: -------------------------------------------------------------------------------- 1 | initTierPriceableTrait(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | annotations: false 5 | http_method_override: false 6 | handle_all_throwables: true 7 | 8 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 9 | # Remove or comment this section to explicitly disable session support. 10 | session: 11 | handler_id: null 12 | cookie_secure: auto 13 | cookie_samesite: lax 14 | 15 | #esi: true 16 | #fragments: true 17 | php_errors: 18 | log: true 19 | 20 | when@test: 21 | framework: 22 | test: true 23 | session: 24 | storage_factory_id: session.storage.factory.mock_file 25 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | handle($request); 28 | $response->send(); 29 | $kernel->terminate($request, $response); 30 | -------------------------------------------------------------------------------- /features/cart/order_price.feature: -------------------------------------------------------------------------------- 1 | @tierprices 2 | Feature: Unit price changes based on tier price 3 | In order to use tierprices and get a discount 4 | As a customer 5 | The cart has to update the unit prices 6 | 7 | Background: 8 | Given the store operates on a single channel in "United States" 9 | 10 | @ui 11 | Scenario: User buys a product that has no tier price 12 | Given the store has a product "The Pug Mug" priced at "$7.00" 13 | When I add this product to the cart 14 | Then I should be on my cart summary page 15 | And I should see "The Pug Mug" with unit price "$7.00" in my cart 16 | 17 | @ui 18 | Scenario: User buys a product that has a tierprice but that does not apply 19 | Given the store has a product "Cool Product" priced at "$11.32" 20 | And "this product" has a tier price at 5 with "$10.00" 21 | When I add this product to the cart 22 | Then I should be on my cart summary page 23 | And I should see "Cool Product" with unit price "$11.32" in my cart 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /var/ 3 | /node_modules/ 4 | /composer.lock 5 | 6 | /etc/build/* 7 | !/etc/build/.gitignore 8 | 9 | /tests/Application/yarn.lock 10 | 11 | /.phpunit.result.cache 12 | /behat.yml 13 | /phpspec.yml 14 | /phpunit.xml 15 | .phpunit.result.cache 16 | 17 | # Symfony CLI https://symfony.com/doc/current/setup/symfony_server.html#different-php-settings-per-project 18 | /.php-version 19 | /php.ini 20 | 21 | ###> symfony/framework-bundle ### 22 | /.env.local 23 | /.env.local.php 24 | /.env.*.local 25 | /config/secrets/prod/prod.decrypt.private.php 26 | /public/bundles/ 27 | /var/ 28 | /vendor/ 29 | ###< symfony/framework-bundle ### 30 | 31 | ###> phpunit/phpunit ### 32 | /phpunit.xml 33 | .phpunit.result.cache 34 | ###< phpunit/phpunit ### 35 | 36 | ###> symfony/webpack-encore-bundle ### 37 | /node_modules/ 38 | /public/build/ 39 | npm-debug.log 40 | yarn-error.log 41 | ###< symfony/webpack-encore-bundle ### 42 | 43 | ###> lexik/jwt-authentication-bundle ### 44 | /config/jwt/*.pem 45 | ###< lexik/jwt-authentication-bundle ### 46 | -------------------------------------------------------------------------------- /config/doctrine/ProductVariant.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/Application/config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | metadata_cache_driver: 4 | type: service 5 | id: doctrine.system_cache_provider 6 | query_cache_driver: 7 | type: service 8 | id: doctrine.system_cache_provider 9 | result_cache_driver: 10 | type: service 11 | id: doctrine.result_cache_provider 12 | 13 | services: 14 | doctrine.result_cache_provider: 15 | class: Symfony\Component\Cache\DoctrineProvider 16 | public: false 17 | arguments: 18 | - '@doctrine.result_cache_pool' 19 | doctrine.system_cache_provider: 20 | class: Symfony\Component\Cache\DoctrineProvider 21 | public: false 22 | arguments: 23 | - '@doctrine.system_cache_pool' 24 | 25 | framework: 26 | cache: 27 | pools: 28 | doctrine.result_cache_pool: 29 | adapter: cache.app 30 | doctrine.system_cache_pool: 31 | adapter: cache.system 32 | -------------------------------------------------------------------------------- /tests/Application/config/routes/sylius_shop.yaml: -------------------------------------------------------------------------------- 1 | sylius_shop: 2 | resource: "@SyliusShopBundle/Resources/config/routing.yml" 3 | prefix: /{_locale} 4 | requirements: 5 | _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ 6 | 7 | sylius_shop_payum: 8 | resource: "@SyliusPayumBundle/Resources/config/routing/integrations/sylius_shop.yaml" 9 | 10 | sylius_payment_notify: 11 | resource: "@SyliusPaymentBundle/Resources/config/routing/integrations/sylius.yaml" 12 | 13 | sylius_shop_default_locale: 14 | path: / 15 | methods: [GET] 16 | defaults: 17 | _controller: sylius_shop.controller.locale_switch::switchAction 18 | 19 | # see https://web.dev/change-password-url/ 20 | sylius_shop_request_password_reset_token_redirect: 21 | path: /.well-known/change-password 22 | methods: [GET] 23 | controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction 24 | defaults: 25 | route: sylius_shop_request_password_reset_token 26 | permanent: false 27 | -------------------------------------------------------------------------------- /config/app/twig_hooks/admin.yaml: -------------------------------------------------------------------------------- 1 | sylius_twig_hooks: 2 | hooks: 3 | # Product Variant Menu 4 | sylius_admin.product_variant.update.content.form.side_navigation: 5 | brille24_tierprice: 6 | template: '@Brille24SyliusTierPricePlugin/Admin/product_variant/form/side_navigation/tierprice.html.twig' 7 | 8 | # Product Variant Hook 9 | sylius_admin.product_variant.update.content.form.sections: 10 | brille24_tierprice: 11 | template: '@Brille24SyliusTierPricePlugin/Admin/product_variant/form/sections/tierprice.html.twig' 12 | 13 | # Product Variant Tierprices 14 | sylius_admin.product_variant.update.content.form.sections.tierprice: 15 | brille24_tierprice_error: 16 | priority: 255 17 | template: '@Brille24SyliusTierPricePlugin/Admin/product_variant/form/sections/tierprice/errors.html.twig' 18 | brille24_tierprice_form: 19 | template: '@Brille24SyliusTierPricePlugin/Admin/product_variant/form/sections/tierprice/form.html.twig' 20 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | src/Tests 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /UPGRADE-2.md: -------------------------------------------------------------------------------- 1 | ## Using the new plugin structure 2 | 3 | The root folder for the plugin is now the plugin itself which means the following directories have been moved: 4 | - src/Resources/config -> config/ 5 | - src/Resources/views -> templates/ 6 | - src/Resources/translations -> translations/ 7 | 8 | For your project this means: 9 | ```yaml 10 | # config/packages/brille24_sylius_tierprice_plugin.yaml 11 | imports: 12 | - { resource: "@Brille24SyliusTierPricePlugin/config/config.yaml" } 13 | ``` 14 | ```yaml 15 | # config/routing.yaml 16 | 17 | brille24_tierprice_bundle: 18 | resource: '@Brille24SyliusTierPricePlugin/config/routing.yml' 19 | ``` 20 | 21 | ### The templates have been split to match the template structure in Sylius. 22 | The Sylius 2 does not support extending the ProductVariant Menu via a listener, so templates have been created instead and the MenuListeners have been removed. 23 | 24 | ### The custom javascript has been removed 25 | All custom javascript has been removed as it does not play nicely with the Live Components. This means that the price on the product page does no longer update live. 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mamazu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Validator/TierPriceUniqueConstraint.php: -------------------------------------------------------------------------------- 1 | getVariants()->toArray()[0]; 30 | 31 | $tierPrice = new TierPrice($quantity, $price * 100); 32 | $productVariant->addTierPrice($tierPrice); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes auto; 3 | daemon off; 4 | pid /run/nginx.pid; 5 | 6 | include /etc/nginx/modules-enabled/*.conf; 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | http { 13 | include /etc/nginx/mime.types; 14 | default_type application/octet-stream; 15 | 16 | server_tokens off; 17 | 18 | client_max_body_size 64m; 19 | sendfile on; 20 | tcp_nodelay on; 21 | tcp_nopush on; 22 | 23 | gzip_vary on; 24 | 25 | access_log /var/log/nginx/access.log; 26 | error_log /var/log/nginx/error.log; 27 | 28 | server { 29 | listen 80; 30 | 31 | root /app/tests/Application/public; 32 | index index.php; 33 | 34 | location / { 35 | try_files $uri /index.php$is_args$args; 36 | } 37 | 38 | location ~ \.php$ { 39 | include fastcgi_params; 40 | 41 | fastcgi_pass unix:/var/run/php8-fpm.sock; 42 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 43 | 44 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 45 | fastcgi_param DOCUMENT_ROOT $realpath_root; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Application/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 19 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 20 | } 21 | 22 | if ($input->hasParameterOption('--no-debug', true)) { 23 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 24 | } 25 | 26 | require dirname(__DIR__).'/config/bootstrap.php'; 27 | 28 | if ($_SERVER['APP_DEBUG']) { 29 | umask(0000); 30 | 31 | if (class_exists(Debug::class)) { 32 | Debug::enable(); 33 | } 34 | } 35 | 36 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 37 | $application = new Application($kernel); 38 | $application->run($input); 39 | -------------------------------------------------------------------------------- /tests/Application/config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 11 | if (is_array($env = @include dirname(__DIR__) . '/.env.local.php')) { 12 | $_SERVER += $env; 13 | $_ENV += $env; 14 | } elseif (!class_exists(Dotenv::class)) { 15 | throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); 16 | } elseif (method_exists(Dotenv::class, 'bootEnv')) { 17 | (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env'); 18 | 19 | return; 20 | } else { 21 | // load all the .env files 22 | (new Dotenv(true))->loadEnv(dirname(__DIR__) . '/.env'); 23 | } 24 | 25 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 26 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 27 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], \FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 28 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 20 | __DIR__ . '/src', 21 | __DIR__ . '/tests/Behat', 22 | __DIR__ . '/ecs.php', 23 | ]); 24 | 25 | $ecsConfig->import('vendor/sylius-labs/coding-standard/ecs.php'); 26 | 27 | $ecsConfig->skip([ 28 | VisibilityRequiredFixer::class => ['*Spec.php'], 29 | ]); 30 | 31 | $ecsConfig->ruleWithConfiguration(HeaderCommentFixer::class, [ 32 | 'comment_type' => 'PHPDoc', 33 | 'location' => 'after_open', 34 | 'header' => << 0 %} 9 | {{ 'brille24_tier_price.ui.tier_prices'|trans }} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for tierPrice in tier_prices %} 24 | 25 | 26 | 27 | 28 | {% endfor %} 29 | 30 |
{{ 'sylius.ui.quantity'|trans }}{{ 'sylius.ui.unit_price'|trans }}
{{ 1|number_format }}{{ money.convertAndFormat(product_variant.channelPricings[sylius.channel.code].price) }}
{{ tierPrice.qty|number_format }}{{ money.convertAndFormat(tierPrice.price, sylius.channel.baseCurrency) }}
31 | 32 |
33 | {% endif %} 34 | -------------------------------------------------------------------------------- /src/DependencyInjection/Brille24SyliusTierPriceExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($this->getConfiguration([], $container), $configs); 28 | 29 | $loder = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../../config')); 30 | $loder->load('services.php'); 31 | } 32 | 33 | public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface 34 | { 35 | return new Configuration(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Form/Extension/ProductVariantTypeExtension.php: -------------------------------------------------------------------------------- 1 | add('tierPrices', LiveCollectionType::class, [ 27 | 'entry_type' => TierPriceType::class, 28 | 'entry_options' => ['label' => false], 29 | 'allow_add' => true, 30 | 'allow_delete' => true, 31 | 'by_reference' => false, 32 | 'block_name' => 'entry', 33 | ]); 34 | } 35 | 36 | public static function getExtendedTypes(): array 37 | { 38 | return [ProductVariantType::class]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Repository/TierPriceRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | children() 30 | ->scalarNode('channel') 31 | ->isRequired() 32 | ->cannotBeEmpty() 33 | ->end() 34 | ->scalarNode('product_variant') 35 | ->isRequired() 36 | ->cannotBeEmpty() 37 | ->end() 38 | ->integerNode('quantity') 39 | ->isRequired() 40 | ->end() 41 | ->integerNode('price') 42 | ->isRequired() 43 | ->end() 44 | ->end(); 45 | } 46 | 47 | public function getName(): string 48 | { 49 | return 'tier_prices'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.feature] 19 | indent_style = space 20 | indent_size = 4 21 | 22 | [*.js] 23 | indent_style = space 24 | indent_size = 2 25 | 26 | [*.json] 27 | indent_style = space 28 | indent_size = 2 29 | 30 | [*.md] 31 | indent_style = space 32 | indent_size = 4 33 | trim_trailing_whitespace = false 34 | 35 | [*.neon] 36 | indent_style = space 37 | indent_size = 4 38 | 39 | [*.php] 40 | indent_style = space 41 | indent_size = 4 42 | 43 | [*.sh] 44 | indent_style = space 45 | indent_size = 4 46 | 47 | [*.{yaml,yml}] 48 | indent_style = space 49 | indent_size = 4 50 | trim_trailing_whitespace = false 51 | 52 | [.babelrc] 53 | indent_style = space 54 | indent_size = 2 55 | 56 | [.gitmodules] 57 | indent_style = tab 58 | indent_size = 4 59 | 60 | [.php_cs{,.dist}] 61 | indent_style = space 62 | indent_size = 4 63 | 64 | [composer.json] 65 | indent_style = space 66 | indent_size = 4 67 | 68 | [package.json] 69 | indent_style = space 70 | indent_size = 2 71 | 72 | [phpspec.yml{,.dist}] 73 | indent_style = space 74 | indent_size = 4 75 | 76 | [phpstan.neon] 77 | indent_style = space 78 | indent_size = 4 79 | 80 | [phpunit.xml{,.dist}] 81 | indent_style = space 82 | indent_size = 4 83 | -------------------------------------------------------------------------------- /config/validation/validation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /config/doctrine/TierPrice.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/icons/symfony.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '16' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | use_savepoints: true 11 | orm: 12 | auto_generate_proxy_classes: true 13 | enable_lazy_ghost_objects: true 14 | report_fields_where_declared: true 15 | validate_xml_mapping: true 16 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 17 | auto_mapping: true 18 | mappings: 19 | App: 20 | type: attribute 21 | is_bundle: false 22 | dir: '%kernel.project_dir%/src/Entity' 23 | prefix: 'App\Entity' 24 | alias: App 25 | 26 | when@test: 27 | doctrine: 28 | dbal: 29 | # "TEST_TOKEN" is typically set by ParaTest 30 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 31 | 32 | when@prod: 33 | doctrine: 34 | orm: 35 | auto_generate_proxy_classes: false 36 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 37 | query_cache_driver: 38 | type: pool 39 | pool: doctrine.system_cache_pool 40 | result_cache_driver: 41 | type: pool 42 | pool: doctrine.result_cache_pool 43 | 44 | framework: 45 | cache: 46 | pools: 47 | doctrine.result_cache_pool: 48 | adapter: cache.app 49 | doctrine.system_cache_pool: 50 | adapter: cache.system 51 | -------------------------------------------------------------------------------- /tests/Application/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const Encore = require('@symfony/webpack-encore'); 3 | 4 | const SyliusAdmin = require('@sylius-ui/admin'); 5 | const SyliusShop = require('@sylius-ui/shop'); 6 | 7 | // Admin config 8 | const adminConfig = SyliusAdmin.getWebpackConfig(path.resolve(__dirname)); 9 | 10 | // Shop config 11 | const shopConfig = SyliusShop.getWebpackConfig(path.resolve(__dirname)); 12 | 13 | // App shop config 14 | Encore 15 | .setOutputPath('public/build/app/shop') 16 | .setPublicPath('/build/app/shop') 17 | .addEntry('app-shop-entry', './assets/shop/entrypoint.js') 18 | .disableSingleRuntimeChunk() 19 | .cleanupOutputBeforeBuild() 20 | .enableSourceMaps(!Encore.isProduction()) 21 | .enableVersioning(Encore.isProduction()) 22 | .enableSassLoader() 23 | ; 24 | 25 | const appShopConfig = Encore.getWebpackConfig(); 26 | 27 | appShopConfig.externals = Object.assign({}, appShopConfig.externals, { window: 'window', document: 'document' }); 28 | appShopConfig.name = 'app.shop'; 29 | 30 | Encore.reset(); 31 | 32 | // App admin config 33 | Encore 34 | .setOutputPath('public/build/app/admin') 35 | .setPublicPath('/build/app/admin') 36 | .addEntry('app-admin-entry', './assets/admin/entrypoint.js') 37 | .disableSingleRuntimeChunk() 38 | .cleanupOutputBeforeBuild() 39 | .enableSourceMaps(!Encore.isProduction()) 40 | .enableVersioning(Encore.isProduction()) 41 | .enableSassLoader(); 42 | 43 | const appAdminConfig = Encore.getWebpackConfig(); 44 | 45 | appAdminConfig.externals = Object.assign({}, appAdminConfig.externals, { window: 'window', document: 'document' }); 46 | appAdminConfig.name = 'app.admin'; 47 | 48 | module.exports = [shopConfig, adminConfig, appShopConfig, appAdminConfig]; 49 | -------------------------------------------------------------------------------- /src/Factory/TierPriceFactory.php: -------------------------------------------------------------------------------- 1 | factory->createNew(); 32 | } 33 | 34 | /** @inheritdoc */ 35 | public function createAtProductVariant( 36 | ProductVariantInterface $productVariant, 37 | array $options = [], 38 | ): TierPriceInterface { 39 | Assert::integer($options['quantity']); 40 | Assert::nullOrIsInstanceOf($options['channel'], ChannelInterface::class); 41 | Assert::integer($options['price']); 42 | 43 | /** @var TierPriceInterface $tierPrice */ 44 | $tierPrice = $this->createNew(); 45 | 46 | $tierPrice->setQty($options['quantity']); 47 | $tierPrice->setChannel($options['channel']); 48 | $tierPrice->setPrice($options['price']); 49 | $tierPrice->setProductVariant($productVariant); 50 | 51 | $productVariant->addTierPrice($tierPrice); 52 | 53 | return $tierPrice; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Services/TierPriceFinder.php: -------------------------------------------------------------------------------- 1 | getGroup(); 40 | } 41 | $possibleTierPrices = $this->tierPriceRepository->getSortedTierPrices($tierPriceableEntity, $channel, $group); 42 | 43 | $cheapestTierPrice = null; 44 | foreach ($possibleTierPrices as $tierPrice) { 45 | if ($tierPrice->getQty() > $quantity) { 46 | break; 47 | } 48 | $cheapestTierPrice = $tierPrice; 49 | } 50 | 51 | return $cheapestTierPrice; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config/packages/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | webpack_encore: 2 | # The path where Encore is building the assets - i.e. Encore.setOutputPath() 3 | output_path: '%kernel.project_dir%/public/build' 4 | # If multiple builds are defined (as shown below), you can disable the default build: 5 | # output_path: false 6 | 7 | # Set attributes that will be rendered on all script and link tags 8 | script_attributes: 9 | defer: true 10 | # Uncomment (also under link_attributes) if using Turbo Drive 11 | # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change 12 | # 'data-turbo-track': reload 13 | # link_attributes: 14 | # Uncomment if using Turbo Drive 15 | # 'data-turbo-track': reload 16 | 17 | # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') 18 | # crossorigin: 'anonymous' 19 | 20 | # Preload all rendered script and link tags automatically via the HTTP/2 Link header 21 | # preload: true 22 | 23 | # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data 24 | # strict_mode: false 25 | 26 | # If you have multiple builds: 27 | # builds: 28 | # frontend: '%kernel.project_dir%/public/frontend/build' 29 | 30 | # pass the build name as the 3rd argument to the Twig functions 31 | # {{ encore_entry_script_tags('entry1', null, 'frontend') }} 32 | 33 | framework: 34 | assets: 35 | json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' 36 | 37 | #when@prod: 38 | # webpack_encore: 39 | # # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) 40 | # # Available in version 1.2 41 | # cache: true 42 | 43 | #when@test: 44 | # webpack_encore: 45 | # strict_mode: false 46 | -------------------------------------------------------------------------------- /UPGRADE-1.3.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | ## v0.6 to v0.7 3 | * See the "Update plugin from Sylius `v1.2.X` TO `v1.3.0`" 4 | * Method signature of tierprice factory has changed: 5 | `public function createAtProductVariant(array $options = [], ProductVariant $productVariant)` 6 | to 7 | `public function createAtProductVariant(ProductVariant $productVariant, array $options = [])` 8 | 9 | ## Upgrade plugin from Sylius `v1.2.X` TO `v1.3.0` 10 | * Run `composer require sylius/sylius:~1.3.0 --no-update` 11 | 12 | * Add the following code in your `behat.yml(.dist)` file: 13 | 14 | ```yaml 15 | default: 16 | extensions: 17 | FriendsOfBehat\SymfonyExtension: 18 | env_file: ~ 19 | ``` 20 | 21 | * Incorporate changes from the following files into plugin's test application: 22 | 23 | * [`tests/Application/package.json`](https://github.com/Sylius/PluginSkeleton/blob/1.3/tests/Application/package.json) ([see diff](https://github.com/Sylius/PluginSkeleton/pull/134/files#diff-726e1353c14df7d91379c0dea6b30eef)) 24 | * [`tests/Application/.babelrc`](https://github.com/Sylius/PluginSkeleton/blob/1.3/tests/Application/.babelrc) ([see diff](https://github.com/Sylius/PluginSkeleton/pull/134/files#diff-a2527d9d8ad55460b2272274762c9386)) 25 | * [`tests/Application/.eslintrc.js`](https://github.com/Sylius/PluginSkeleton/blob/1.3/tests/Application/.eslintrc.js) ([see diff](https://github.com/Sylius/PluginSkeleton/pull/134/files#diff-396c8c412b119deaa7dd84ae28ae04ca)) 26 | 27 | * Update PHP and JS dependencies by running `composer update` and `(cd tests/Application && yarn upgrade)` 28 | 29 | * Clear cache by running `(cd tests/Application && bin/console cache:clear)` 30 | 31 | * Install assets by `(cd tests/Application && bin/console assets:install web)` and `(cd tests/Application && yarn build)` 32 | 33 | * optionally, remove the build for PHP 7.1. in `.travis.yml` 34 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords 3 | password_hashers: 4 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' 5 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 6 | providers: 7 | users_in_memory: { memory: null } 8 | firewalls: 9 | dev: 10 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 11 | security: false 12 | main: 13 | lazy: true 14 | provider: users_in_memory 15 | 16 | # activate different ways to authenticate 17 | # https://symfony.com/doc/current/security.html#the-firewall 18 | 19 | # https://symfony.com/doc/current/security/impersonating_user.html 20 | # switch_user: true 21 | 22 | # Easy way to control access for large sections of your site 23 | # Note: Only the *first* access control that matches will be used 24 | access_control: 25 | # - { path: ^/admin, roles: ROLE_ADMIN } 26 | # - { path: ^/profile, roles: ROLE_USER } 27 | 28 | when@test: 29 | security: 30 | password_hashers: 31 | # By default, password hashers are resource intensive and take time. This is 32 | # important to generate secure password hashes. In tests however, secure hashes 33 | # are not important, waste resources and increase test times. The following 34 | # reduces the work factor to the lowest possible values. 35 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 36 | algorithm: auto 37 | cost: 4 # Lowest possible value for bcrypt 38 | time_cost: 3 # Lowest possible value for argon 39 | memory_cost: 10 # Lowest possible value for argon 40 | -------------------------------------------------------------------------------- /src/Services/OrderPricesRecalculator.php: -------------------------------------------------------------------------------- 1 | orderProcessor->process($order); 38 | 39 | $channel = $order->getChannel(); 40 | 41 | foreach ($order->getItems() as $item) { 42 | if ($item->isImmutable()) { 43 | continue; 44 | } 45 | 46 | /** @var ProductVariantInterface $variant */ 47 | $variant = $item->getVariant(); 48 | 49 | $item->setUnitPrice($this->productVariantPriceCalculator->calculate( 50 | $variant, 51 | ['channel' => $channel, 'quantity' => $item->getQuantity(), 'customer' => $order->getCustomer()], 52 | )); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Application/.env: -------------------------------------------------------------------------------- 1 | # This file is a "template" of which env vars needs to be defined in your configuration or in an .env file 2 | # Set variables here that may be different on each deployment target of the app, e.g. development, staging, production. 3 | # https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration 4 | 5 | ###> symfony/framework-bundle ### 6 | APP_ENV=dev 7 | APP_DEBUG=1 8 | APP_SECRET=EDITME 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> doctrine/doctrine-bundle ### 12 | # Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 13 | # For a sqlite database, use: "sqlite:///%kernel.project_dir%/var/data.db" 14 | # Set "serverVersion" to your server version to avoid edge-case exceptions and extra database calls 15 | DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db 16 | ###< doctrine/doctrine-bundle ### 17 | 18 | ###> lexik/jwt-authentication-bundle ### 19 | JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem 20 | JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem 21 | JWT_PASSPHRASE=acme_plugin_development 22 | ###< lexik/jwt-authentication-bundle ### 23 | 24 | ###> symfony/mailer ### 25 | MAILER_DSN=null://null 26 | ###< symfony/mailer ### 27 | 28 | ###> symfony/messenger ### 29 | SYLIUS_MESSENGER_TRANSPORT_MAIN_DSN=doctrine://default 30 | SYLIUS_MESSENGER_TRANSPORT_MAIN_FAILED_DSN=doctrine://default?queue_name=main_failed 31 | SYLIUS_MESSENGER_TRANSPORT_CATALOG_PROMOTION_REMOVAL_DSN=doctrine://default?queue_name=catalog_promotion_removal 32 | SYLIUS_MESSENGER_TRANSPORT_CATALOG_PROMOTION_REMOVAL_FAILED_DSN=doctrine://default?queue_name=catalog_promotion_removal_failed 33 | SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_DSN=doctrine://default?queue_name=payment_request 34 | SYLIUS_MESSENGER_TRANSPORT_PAYMENT_REQUEST_FAILED_DSN=doctrine://default?queue_name=payment_request_failed 35 | ###< symfony/messenger ### 36 | -------------------------------------------------------------------------------- /src/Form/TierPriceType.php: -------------------------------------------------------------------------------- 1 | add('qty', NumberType::class, [ 30 | 'label' => 'sylius.ui.amount', 31 | 'required' => true, 32 | 'empty_data' => 0, 33 | ]); 34 | 35 | $builder->add('price', MoneyType::class, [ 36 | 'label' => 'sylius.ui.price', 37 | 'required' => true, 38 | 'currency' => $options['currency'], 39 | 'empty_data' => 0, 40 | ]); 41 | 42 | $builder->add('customerGroup', CustomerGroupChoiceType::class, [ 43 | 'required' => false, 44 | ]); 45 | 46 | $builder->add('channel', ChannelChoiceType::class, [ 47 | 'attr' => ['style' => 'display:none'], 48 | ]); 49 | } 50 | 51 | public function configureOptions(OptionsResolver $resolver): void 52 | { 53 | $resolver->setRequired(['currency']); 54 | $resolver->setDefaults([ 55 | 'data_class' => TierPrice::class, 56 | 'currency' => 'USD', 57 | ]); 58 | } 59 | 60 | public function getBlockPrefix(): string 61 | { 62 | return 'brille24_tier_price'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /bin/create_node_symlink.php: -------------------------------------------------------------------------------- 1 | `' . NODE_MODULES_FOLDER_NAME . '` already exists as a link or folder, keeping existing as may be intentional.' . PHP_EOL; 11 | exit(0); 12 | } else { 13 | echo '> Invalid symlink `' . NODE_MODULES_FOLDER_NAME . '` detected, recreating...' . PHP_EOL; 14 | if (!@unlink(NODE_MODULES_FOLDER_NAME)) { 15 | echo '> Could not delete file `' . NODE_MODULES_FOLDER_NAME . '`.' . PHP_EOL; 16 | exit(1); 17 | } 18 | } 19 | } 20 | 21 | /* try to create the symlink using PHP internals... */ 22 | $success = @symlink(PATH_TO_NODE_MODULES, NODE_MODULES_FOLDER_NAME); 23 | 24 | /* if case it has failed, but OS is Windows... */ 25 | if (!$success && strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 26 | /* ...then try a different approach which does not require elevated permissions and folder to exist */ 27 | echo '> This system is running Windows, creation of links requires elevated privileges,' . PHP_EOL; 28 | echo '> and target path to exist. Fallback to NTFS Junction:' . PHP_EOL; 29 | exec(sprintf('mklink /J %s %s 2> NUL', NODE_MODULES_FOLDER_NAME, PATH_TO_NODE_MODULES), $output, $returnCode); 30 | $success = $returnCode === 0; 31 | if (!$success) { 32 | echo '> Failed o create the required symlink' . PHP_EOL; 33 | exit(2); 34 | } 35 | } 36 | 37 | $path = @readlink(NODE_MODULES_FOLDER_NAME); 38 | /* check if link points to the intended directory */ 39 | if ($path && realpath($path) === realpath(PATH_TO_NODE_MODULES)) { 40 | echo '> Successfully created the symlink.' . PHP_EOL; 41 | exit(0); 42 | } 43 | 44 | echo '> Failed to create the symlink to `' . NODE_MODULES_FOLDER_NAME . '`.' . PHP_EOL; 45 | exit(3); 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: sylius/standard:1.11-traditional-alpine 4 | environment: 5 | APP_ENV: "dev" 6 | DATABASE_URL: "mysql://root:mysql@mysql/sylius_%kernel.environment%?charset=utf8mb4" 7 | # DATABASE_URL: "pgsql://root:postgres@postgres/sylius_%kernel.environment%?charset=utf8" # When using postgres 8 | PHP_DATE_TIMEZONE: "Europe/Warsaw" 9 | volumes: 10 | - ./:/app:delegated 11 | - ./.docker/php/php.ini:/etc/php8/php.ini:delegated 12 | - ./.docker/nginx/nginx.conf:/etc/nginx/nginx.conf:delegated 13 | ports: 14 | - 80:80 15 | depends_on: 16 | - mysql 17 | networks: 18 | - sylius 19 | 20 | mysql: 21 | image: mysql:8.0 22 | platform: linux/amd64 23 | environment: 24 | MYSQL_ROOT_PASSWORD: mysql 25 | ports: 26 | - ${MYSQL_PORT:-3306}:3306 27 | networks: 28 | - sylius 29 | 30 | # postgres: 31 | # image: postgres:14-alpine 32 | # environment: 33 | # POSTGRES_USER: root 34 | # POSTGRES_PASSWORD: postgres 35 | # ports: 36 | # - ${POSTGRES_PORT:-5432}:5432 37 | # networks: 38 | # - sylius 39 | 40 | ###> doctrine/doctrine-bundle ### 41 | database: 42 | image: postgres:${POSTGRES_VERSION:-16}-alpine 43 | environment: 44 | POSTGRES_DB: ${POSTGRES_DB:-app} 45 | # You should definitely change the password in production 46 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} 47 | POSTGRES_USER: ${POSTGRES_USER:-app} 48 | volumes: 49 | - database_data:/var/lib/postgresql/data:rw 50 | # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! 51 | # - ./docker/db/data:/var/lib/postgresql/data:rw 52 | ###< doctrine/doctrine-bundle ### 53 | 54 | networks: 55 | sylius: 56 | driver: bridge 57 | 58 | volumes: 59 | ###> doctrine/doctrine-bundle ### 60 | database_data: 61 | ###< doctrine/doctrine-bundle ### 62 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 7 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 8 | Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], 9 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 10 | Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], 11 | Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], 12 | Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true], 13 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 14 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 15 | Sylius\Bundle\ThemeBundle\SyliusThemeBundle::class => ['all' => true], 16 | BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], 17 | Sylius\Bundle\MailerBundle\SyliusMailerBundle::class => ['all' => true], 18 | Sylius\Bundle\GridBundle\SyliusGridBundle::class => ['all' => true], 19 | Sylius\Bundle\FixturesBundle\SyliusFixturesBundle::class => ['all' => true], 20 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 21 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 22 | SyliusLabs\DoctrineMigrationsExtraBundle\SyliusLabsDoctrineMigrationsExtraBundle::class => ['all' => true], 23 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], 24 | League\FlysystemBundle\FlysystemBundle::class => ['all' => true], 25 | Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], 26 | Knp\Bundle\GaufretteBundle\KnpGaufretteBundle::class => ['all' => true], 27 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 28 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], 29 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 30 | Brille24\SyliusTierPricePlugin\Brille24SyliusTierPricePlugin::class => ['all' => true] 31 | ]; 32 | -------------------------------------------------------------------------------- /tests/Behat/Resources/suites.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | ui_tierprices: 4 | contexts: 5 | # Database cleaner 6 | - sylius.behat.context.hook.doctrine_orm 7 | 8 | - sylius.behat.context.transform.channel 9 | - sylius.behat.context.transform.lexical 10 | - sylius.behat.context.transform.product 11 | - sylius.behat.context.transform.product_variant 12 | 13 | - sylius.behat.context.setup.channel 14 | - sylius.behat.context.setup.currency 15 | - sylius.behat.context.setup.product 16 | 17 | - sylius.behat.context.ui.channel 18 | - sylius.behat.context.ui.shop.cart 19 | - sylius.behat.context.ui.shop.currency 20 | - sylius.behat.context.ui.shop.product 21 | 22 | - sylius.behat.context.setup.admin_security 23 | - sylius.behat.context.setup.channel 24 | - sylius.behat.context.setup.product 25 | - sylius.behat.context.ui.shop.cart 26 | 27 | - sylius.behat.context.transform.channel 28 | - sylius.behat.context.transform.currency 29 | - sylius.behat.context.transform.lexical 30 | - sylius.behat.context.transform.shared_storage 31 | - sylius.behat.context.transform.product 32 | - sylius.behat.context.transform.product_option 33 | - sylius.behat.context.transform.tax_category 34 | - sylius.behat.context.transform.zone 35 | 36 | - sylius.behat.context.setup.shop_security 37 | - sylius.behat.context.setup.channel 38 | - sylius.behat.context.setup.currency 39 | - sylius.behat.context.setup.exchange_rate 40 | - sylius.behat.context.setup.product 41 | - sylius.behat.context.setup.shipping 42 | - sylius.behat.context.setup.taxation 43 | - sylius.behat.context.setup.user 44 | - sylius.behat.context.setup.zone 45 | 46 | - sylius.behat.context.ui.channel 47 | - sylius.behat.context.ui.shop.cart 48 | - sylius.behat.context.ui.shop.currency 49 | - sylius.behat.context.ui.shop.product 50 | - sylius.behat.context.ui.shop.registration 51 | - sylius.behat.context.ui.user 52 | 53 | - brille24.behat.context.tierprice 54 | filters: 55 | tags: "@tierprices && @ui" 56 | -------------------------------------------------------------------------------- /config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | formatter: monolog.formatter.json 63 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=3d7706c036852376581374addd393ba1 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> symfony/messenger ### 23 | # Choose one of the transports below 24 | # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages 25 | # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages 26 | MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 27 | ###< symfony/messenger ### 28 | 29 | ###> symfony/mailer ### 30 | MAILER_DSN=null://null 31 | ###< symfony/mailer ### 32 | 33 | ###> doctrine/doctrine-bundle ### 34 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 35 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 36 | # 37 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 38 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 39 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 40 | DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" 41 | ###< doctrine/doctrine-bundle ### 42 | 43 | ###> lexik/jwt-authentication-bundle ### 44 | JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem 45 | JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem 46 | JWT_PASSPHRASE=efc4224f86e8cbce23e20b4290450160e97a3dbe3aa9fe68dc5a21406c87fbd7 47 | ###< lexik/jwt-authentication-bundle ### 48 | -------------------------------------------------------------------------------- /src/Tests/Factory/TierPriceFactoryTest.php: -------------------------------------------------------------------------------- 1 | baseFactory = $this->createMock(FactoryInterface::class); 35 | 36 | $this->subject = new TierPriceFactory($this->baseFactory); 37 | } 38 | 39 | public function test_createNew(): void 40 | { 41 | $tierPrice = $this->createMock(TierPrice::class); 42 | 43 | $this->baseFactory->method('createNew')->willReturn($tierPrice); 44 | 45 | self::assertSame($tierPrice, $this->subject->createNew()); 46 | } 47 | 48 | public function test_createAtProductVariant(): void 49 | { 50 | $channel = $this->createMock(ChannelInterface::class); 51 | $productVariant = $this->createMock(ProductVariantInterface::class); 52 | 53 | $tierPrice = $this->createMock(TierPrice::class); 54 | $tierPrice->expects(self::once())->method('setQty')->with(10); 55 | $tierPrice->expects(self::once())->method('setChannel')->with($channel); 56 | $tierPrice->expects(self::once())->method('setPrice')->with(100); 57 | $tierPrice->expects(self::once())->method('setProductVariant')->with($productVariant); 58 | 59 | $productVariant->method('addTierPrice')->with($tierPrice); 60 | $this->baseFactory->expects(self::once())->method('createNew')->willReturn($tierPrice); 61 | 62 | $this->subject->createAtProductVariant($productVariant, [ 63 | 'quantity' => 10, 64 | 'channel' => $channel, 65 | 'price' => 100, 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /behat.yml.dist: -------------------------------------------------------------------------------- 1 | imports: 2 | - vendor/sylius/sylius/src/Sylius/Behat/Resources/config/suites.yml 3 | - tests/Behat/Resources/suites.yml 4 | 5 | default: 6 | formatters: 7 | pretty: 8 | verbose: true 9 | paths: false 10 | snippets: false 11 | 12 | extensions: 13 | DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~ 14 | Robertfausk\Behat\PantherExtension: ~ 15 | 16 | FriendsOfBehat\MinkDebugExtension: 17 | directory: etc/build 18 | clean_start: false 19 | screenshot: true 20 | 21 | Behat\MinkExtension: 22 | files_path: "%paths.base%/vendor/sylius/sylius/src/Sylius/Behat/Resources/fixtures/" 23 | base_url: "https://127.0.0.1:8080/" 24 | default_session: symfony 25 | javascript_session: panther 26 | sessions: 27 | symfony: 28 | symfony: ~ 29 | chromedriver: 30 | chrome: 31 | api_url: http://127.0.0.1:9222 32 | validate_certificate: false 33 | chrome_headless_second_session: 34 | chrome: 35 | api_url: http://127.0.0.1:9222 36 | validate_certificate: false 37 | panther: 38 | panther: 39 | options: 40 | webServerDir: '%paths.base%/tests/Application/public' 41 | manager_options: 42 | connection_timeout_in_ms: 5000 43 | request_timeout_in_ms: 120000 44 | chromedriver_arguments: 45 | - --log-path=etc/build/chromedriver.log 46 | - --verbose 47 | capabilities: 48 | acceptSslCerts: true 49 | acceptInsecureCerts: true 50 | unexpectedAlertBehaviour: accept 51 | show_auto: false 52 | 53 | FriendsOfBehat\SymfonyExtension: 54 | bootstrap: tests/Application/config/bootstrap.php 55 | kernel: 56 | class: Tests\Brille24\SyliusTierPricePlugin\Application\Kernel 57 | 58 | FriendsOfBehat\VariadicExtension: ~ 59 | 60 | FriendsOfBehat\SuiteSettingsExtension: 61 | paths: 62 | - "features" 63 | 64 | SyliusLabs\SuiteTagsExtension: ~ 65 | -------------------------------------------------------------------------------- /src/Repository/TierPriceRepository.php: -------------------------------------------------------------------------------- 1 | findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) 24 | * @method TierPriceInterface|null findOneBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) 25 | */ 26 | class TierPriceRepository extends EntityRepository implements TierPriceRepositoryInterface 27 | { 28 | public function getSortedTierPrices(TierPriceableInterface $productVariant, ChannelInterface $channel, ?CustomerGroupInterface $customerGroup = null): array 29 | { 30 | /* 31 | * If we have a customer group, check for tier prices for that customer group, if we find any return them and 32 | * only them. 33 | */ 34 | if ($customerGroup instanceof CustomerGroupInterface) { 35 | $prices = $this->findBy(['productVariant' => $productVariant, 'channel' => $channel, 'customerGroup' => $customerGroup], ['qty' => 'ASC']); 36 | if (count($prices) > 0) { 37 | return $prices; 38 | } 39 | } 40 | 41 | /* 42 | * If we don't have a customer group or the customer group has no tier prices get the tier prices with 43 | * no group set. 44 | */ 45 | return $this->findBy(['productVariant' => $productVariant, 'channel' => $channel, 'customerGroup' => null], ['qty' => 'ASC']); 46 | } 47 | 48 | public function getTierPriceForQuantity( 49 | TierPriceableInterface $productVariant, 50 | ChannelInterface $channel, 51 | ?CustomerGroupInterface $customerGroup, 52 | int $quantity, 53 | ): ?TierPriceInterface { 54 | return $this->findOneBy([ 55 | 'productVariant' => $productVariant, 56 | 'channel' => $channel, 57 | 'customerGroup' => $customerGroup, 58 | 'qty' => $quantity, 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /templates/Admin/product_variant/form/sections/tierprice/form.html.twig: -------------------------------------------------------------------------------- 1 | {% set form = hookable_metadata.context.form %} 2 | {% set channels = hookable_metadata.context.channels %} 3 | 4 | {# Itterating over the channels #} 5 | {% for channel in channels %} 6 |

{{ channel.name }}

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {# Rendering the table body #} 19 | {% for tierprice in form.tierPrices %} 20 | {% if tierprice.channel.vars['value'] == channel.code %} 21 | 22 | 28 | 35 | 41 | 47 | 48 | {% endif %} 49 | {% endfor %} 50 | 51 |
{{ 'sylius.ui.quantity'|trans }}{{ 'sylius.ui.unit_price'|trans }} {{ 'brille24_tier_price.ui.customer_group'|trans }} {{ 'sylius.ui.delete'|trans }}
23 |
24 | {{ form_widget(tierprice.qty) }} 25 | {{ form_errors(tierprice.qty) }} 26 |
27 |
29 | {{ form_widget(tierprice.channel) }} 30 |
31 | {{ form_widget(tierprice.price, {currency: channel.baseCurrency.code|default('USD')}) }} 32 | {{ form_errors(tierprice.price) }} 33 |
34 |
36 |
37 | {{ form_widget(tierprice.customerGroup, {'attr': {'class': 'ui dropdown'}}) }} 38 | {{ form_errors(tierprice.customerGroup) }} 39 |
40 |
42 | {{ form_widget(tierprice.vars.button_delete, { 43 | label: 'sylius.ui.delete'|trans, 44 | attr: { class: 'btn btn-outline-danger w-100','data-test-tierprice-delete': '' } 45 | }) }} 46 |
52 | 53 | {{ form_widget(form.tierPrices.vars.button_add) }} 54 | {% endfor %} 55 | 56 | -------------------------------------------------------------------------------- /src/Tests/Factory/TierPriceExampleFactoryTest.php: -------------------------------------------------------------------------------- 1 | productVariantRepository = $this->createMock(ProductVariantRepositoryInterface::class); 43 | $this->channelRepository = $this->createMock(ChannelRepositoryInterface::class); 44 | $this->tierPriceFactory = $this->createMock(TierPriceFactoryInterface::class); 45 | 46 | $this->subject = new TierPriceExampleFactory($this->productVariantRepository, $this->channelRepository, $this->tierPriceFactory); 47 | } 48 | 49 | public function test_create(): void 50 | { 51 | $tierPrice = $this->createMock(TierPrice::class); 52 | $productVariant = $this->createMock(ProductVariantInterface::class); 53 | $channel = $this->createMock(ChannelInterface::class); 54 | 55 | $options = [ 56 | 'quantity' => 10, 57 | 'price' => 100, 58 | 'product_variant' => $productVariant, 59 | 'channel' => $channel, 60 | ]; 61 | 62 | /** 63 | * @psalm-suppress PossiblyUndefinedMethod 64 | * @psalm-suppress MixedMethodCall 65 | */ 66 | $this->tierPriceFactory 67 | ->method('createAtProductVariant') 68 | ->with($productVariant, $options) 69 | ->willReturn($tierPrice); 70 | 71 | self::assertSame($tierPrice, $this->subject->create($options)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | 3 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 4 | // It's useful when you use tools that rely on webpack.config.js file. 5 | if (!Encore.isRuntimeEnvironmentConfigured()) { 6 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 7 | } 8 | 9 | Encore 10 | // directory where compiled assets will be stored 11 | .setOutputPath('public/build/') 12 | // public path used by the web server to access the output path 13 | .setPublicPath('/build') 14 | // only needed for CDN's or subdirectory deploy 15 | //.setManifestKeyPrefix('build/') 16 | 17 | /* 18 | * ENTRY CONFIG 19 | * 20 | * Each entry will result in one JavaScript file (e.g. app.js) 21 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 22 | */ 23 | .addEntry('app', './assets/app.js') 24 | 25 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 26 | .splitEntryChunks() 27 | 28 | // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js) 29 | .enableStimulusBridge('./assets/controllers.json') 30 | 31 | // will require an extra script tag for runtime.js 32 | // but, you probably want this, unless you're building a single-page app 33 | .enableSingleRuntimeChunk() 34 | 35 | /* 36 | * FEATURE CONFIG 37 | * 38 | * Enable & configure other features below. For a full 39 | * list of features, see: 40 | * https://symfony.com/doc/current/frontend.html#adding-more-features 41 | */ 42 | .cleanupOutputBeforeBuild() 43 | .enableBuildNotifications() 44 | .enableSourceMaps(!Encore.isProduction()) 45 | // enables hashed filenames (e.g. app.abc123.css) 46 | .enableVersioning(Encore.isProduction()) 47 | 48 | // configure Babel 49 | // .configureBabel((config) => { 50 | // config.plugins.push('@babel/a-babel-plugin'); 51 | // }) 52 | 53 | // enables and configure @babel/preset-env polyfills 54 | .configureBabelPresetEnv((config) => { 55 | config.useBuiltIns = 'usage'; 56 | config.corejs = '3.38'; 57 | }) 58 | 59 | // enables Sass/SCSS support 60 | //.enableSassLoader() 61 | 62 | // uncomment if you use TypeScript 63 | //.enableTypeScriptLoader() 64 | 65 | // uncomment if you use React 66 | //.enableReactPreset() 67 | 68 | // uncomment to get integrity="..." attributes on your script & link tags 69 | // requires WebpackEncoreBundle 1.4 or higher 70 | //.enableIntegrityHashes(Encore.isProduction()) 71 | 72 | // uncomment if you're having problems with a jQuery plugin 73 | //.autoProvidejQuery() 74 | ; 75 | 76 | module.exports = Encore.getWebpackConfig(); 77 | -------------------------------------------------------------------------------- /src/Factory/TierPriceExampleFactory.php: -------------------------------------------------------------------------------- 1 | optionsResolver = new OptionsResolver(); 36 | $this->configureOptions($this->optionsResolver); 37 | } 38 | 39 | /** 40 | * Creates a tier price 41 | * 42 | * @param array $options The configuration of the tier price 43 | * 44 | * @throws EntityNotFoundException 45 | */ 46 | public function create(array $options = []): TierPriceInterface 47 | { 48 | $options = $this->optionsResolver->resolve($options); 49 | 50 | /** @var ProductVariantInterface $productVariant */ 51 | $productVariant = $options['product_variant']; 52 | 53 | return $this->tierPriceFactory->createAtProductVariant($productVariant, $options); 54 | } 55 | 56 | /** 57 | * Configuring the options that are allowed in the factory 58 | */ 59 | protected function configureOptions(OptionsResolver $resolver): void 60 | { 61 | $resolver->setDefault('quantity', 1); 62 | $resolver->setAllowedTypes('quantity', 'integer'); 63 | 64 | $resolver->setDefault('price', 0); 65 | $resolver->setAllowedTypes('price', 'integer'); 66 | 67 | $resolver->setDefault('product_variant', LazyOption::randomOne($this->productVariantRepository)); 68 | $resolver->setAllowedTypes('product_variant', [ProductVariantInterface::class, 'string']); 69 | $resolver->setNormalizer('product_variant', LazyOption::findOneBy($this->productVariantRepository, 'code')); 70 | 71 | $resolver->setDefault('channel', LazyOption::randomOne($this->channelRepository)); 72 | $resolver->setAllowedTypes('channel', [ChannelInterface::class, 'string']); 73 | $resolver->setNormalizer('channel', LazyOption::findOneBy($this->channelRepository, 'code')); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Entity/TierPrice.php: -------------------------------------------------------------------------------- 1 | id; 55 | } 56 | 57 | public function getPrice(): int 58 | { 59 | return $this->price; 60 | } 61 | 62 | public function setPrice(int $price): void 63 | { 64 | $this->price = $price; 65 | } 66 | 67 | public function getQty(): int 68 | { 69 | return $this->qty; 70 | } 71 | 72 | public function setQty(int $qty): void 73 | { 74 | $this->qty = max($qty, 0); 75 | } 76 | 77 | public function getProductVariant(): ProductVariantInterface 78 | { 79 | return $this->productVariant; 80 | } 81 | 82 | public function setProductVariant(ProductVariantInterface $productVariant): void 83 | { 84 | $this->productVariant = $productVariant; 85 | } 86 | 87 | public function getChannel(): ?ChannelInterface 88 | { 89 | return $this->channel; 90 | } 91 | 92 | public function setChannel(?ChannelInterface $channel): void 93 | { 94 | $this->channel = $channel; 95 | } 96 | 97 | public function getCustomerGroup(): ?CustomerGroupInterface 98 | { 99 | return $this->customerGroup; 100 | } 101 | 102 | public function setCustomerGroup(?CustomerGroupInterface $customerGroup): void 103 | { 104 | $this->customerGroup = $customerGroup; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Services/ProductVariantPriceCalculator.php: -------------------------------------------------------------------------------- 1 | 40 | *
  • channel
  • 41 | *
  • quantity
  • 42 | * 43 | */ 44 | public function calculate(ProductVariantInterface $productVariant, array $context): int 45 | { 46 | // Return the base price if the quantity is not provided 47 | if (!array_key_exists('quantity', $context)) { 48 | return $this->basePriceCalculator->calculate($productVariant, $context); 49 | } 50 | 51 | // If customer passed in through $context use that instead of CustomerContextInterface 52 | $customer = $this->customerContext->getCustomer(); 53 | if (array_key_exists('customer', $context)) { 54 | /** @var CustomerInterface $customer */ 55 | $customer = $context['customer']; 56 | } 57 | 58 | // Find a tier price and return it 59 | if ($productVariant instanceof TierPriceableInterface) { 60 | Assert::isInstanceOf($context['channel'], ChannelInterface::class); 61 | Assert::integer($context['quantity']); 62 | 63 | $tierPrice = $this->tierPriceFinder->find($productVariant, $context['channel'], $context['quantity'], $customer); 64 | if ($tierPrice !== null) { 65 | return $tierPrice->getPrice(); 66 | } 67 | } 68 | 69 | // Return the base price if there are no tier prices 70 | return $this->basePriceCalculator->calculate($productVariant, $context); 71 | } 72 | 73 | public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int 74 | { 75 | return $this->basePriceCalculator->calculateOriginal($productVariant, $context); 76 | } 77 | 78 | public function calculateLowestPriceBeforeDiscount(ProductVariantInterface $productVariant, array $context): ?int 79 | { 80 | if (method_exists($this->basePriceCalculator, 'calculateLowestPriceBeforeDiscount')) { 81 | return $this->basePriceCalculator->calculateLowestPriceBeforeDiscount($productVariant, $context); 82 | } 83 | 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brille24/sylius-tierprice-plugin", 3 | "type": "sylius-plugin", 4 | "description": "A plugin that allows to add tierprices to Sylius", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.2", 8 | "sylius/sylius": "~2.0.0" 9 | }, 10 | "require-dev": { 11 | "behat/behat": "^3.16", 12 | "dmore/behat-chrome-extension": "^1.4", 13 | "dmore/chrome-mink-driver": "^2.9", 14 | "friends-of-behat/mink": "^1.11", 15 | "friends-of-behat/mink-browserkit-driver": "^1.6", 16 | "friends-of-behat/mink-debug-extension": "^2.1", 17 | "friends-of-behat/mink-extension": "^2.7", 18 | "friends-of-behat/page-object-extension": "^0.3", 19 | "friends-of-behat/suite-settings-extension": "^1.1", 20 | "friends-of-behat/symfony-extension": "^2.6", 21 | "friends-of-behat/variadic-extension": "^1.6", 22 | "nyholm/psr7": "^1.8", 23 | "phpspec/phpspec": "^7.5", 24 | "phpstan/phpstan": "^1.12", 25 | "phpstan/phpstan-doctrine": "^1.3", 26 | "phpstan/phpstan-webmozart-assert": "^1.2", 27 | "phpunit/phpunit": "^10.5", 28 | "robertfausk/behat-panther-extension": "^1.1", 29 | "sylius-labs/coding-standard": "^4.4", 30 | "sylius-labs/suite-tags-extension": "~0.2", 31 | "sylius/sylius-rector": "^1.0", 32 | "symfony/browser-kit": "^6.4 || ^7.1", 33 | "symfony/debug-bundle": "^6.4 || ^7.1", 34 | "symfony/dotenv": "^6.4 || ^7.1", 35 | "symfony/flex": "^2.4", 36 | "symfony/http-client": "^6.4 || ^7.1", 37 | "symfony/intl": "^6.4 || ^7.1", 38 | "symfony/web-profiler-bundle": "^6.4 || ^7.1", 39 | "symfony/webpack-encore-bundle": "^2.2" 40 | }, 41 | "config": { 42 | "sort-packages": true, 43 | "allow-plugins": { 44 | "dealerdirect/phpcodesniffer-composer-installer": false, 45 | "php-http/discovery": false, 46 | "phpstan/extension-installer": false, 47 | "symfony/flex": false, 48 | "symfony/thanks": false 49 | } 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "2.0-dev" 54 | }, 55 | "symfony": { 56 | "require": "^6.4" 57 | } 58 | }, 59 | "autoload": { 60 | "psr-4": { 61 | "Brille24\\SyliusTierPricePlugin\\": "src/", 62 | "Tests\\Brille24\\SyliusTierPricePlugin\\": "tests/" 63 | } 64 | }, 65 | "autoload-dev": { 66 | "classmap": [ 67 | "tests/Application/Kernel.php" 68 | ] 69 | }, 70 | "scripts": { 71 | "post-install-cmd": [ 72 | "php bin/create_node_symlink.php" 73 | ], 74 | "post-update-cmd": [ 75 | "php bin/create_node_symlink.php" 76 | ], 77 | "post-create-project-cmd": [ 78 | "php bin/create_node_symlink.php" 79 | ], 80 | "auto-scripts": { 81 | "cache:clear": "symfony-cmd", 82 | "assets:install %PUBLIC_DIR%": "symfony-cmd", 83 | "security-checker security:check": "script" 84 | }, 85 | "analyse": [ 86 | "vendor/bin/ecs check --ansi --no-progress-bar --config ecs.php", 87 | "vendor/bin/phpstan analyse -c phpstan.neon --no-progress -l max src/" 88 | ], 89 | "fix": [ 90 | "vendor/bin/ecs check --ansi --no-progress-bar --config ecs.php --fix" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Application/config/jwt/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIJrTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIDbthk+aF5EACAggA 3 | MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBA3DYfh2mXByUxFNke/Wf5SBIIJ 4 | UBckIgXeXBWPLQAAq07pN8uNFMUcUirFuEvbmxVe1PupCCAqriNxi1DqeSu/M7c1 5 | h66y0BqKZu/0G9SVTg63iCKDEiRAM3hLyD2CsjYg8h2LAaqQ9dFYGV0cHRhCXagZ 6 | Sdt9YTfn2rarRbxauMSt0z9zwCaiUrBU4JwSM3g+tD7W0lxAm9TeaqBZek5DIX+j 7 | 3Gom5tPYQe8jvfGMGdMPuanoEwH4WbWzGcqypWriy4JwaggwKCQ4ituWfa9kqMMC 8 | 8HRmBBDg0gtafmQP910RZh18JL2ewF5Pl7GDsLtOj5gNLNuAiQxDCcYRnD4/Cdsl 9 | bH91btmGX1nUVIFViUTW93eBsjBgdgqOMRVxUKkSSX6CmIZWlE3AazgwSbvOvNrN 10 | JGa8X21UwfuS/JHLmfRmgdti0YxRjJkBYLPpcd3ILsi+MMhSHy0uycAM/dB80Q1B 11 | vkW1UXGbCw/PzA5yHrzULzAl69E3Tt5nTVMIIcBGxw2rf+ej+AVjsuOl7etwecdC 12 | gnA90ViNlGOACLVnhsjd4WVF9Oircosf0UYoblwcT6gw1GSVF9pWuu7k5hy/7Pt/ 13 | o1BvonUgz/4VHG+K58qvtnlto+JE0XWzPvukNUyggtekTLyoQCI3ZKge6ui3qLax 14 | N6whHpzFnFVF3GJAisTk5naHFawHNvH7t85pmc+UnjNUUmyl9RStl9LMYDSBKNlR 15 | LzPlJK27E5SLhhyJCni4+UYjH6PdlJuKXJ0365fufJ+5ajHRatwt039xLnK0W+oa 16 | L35NxCuXrn8YxOgJIomt7IrkV3AuxoWxcx4lRFoM0WCdn9SWZVtfFFiyX/Xr1qDg 17 | dUysw3/bePEkOKr5JWx09hT0OKDpkwLFo2Ljtvjln4EMXYEvvVqFciKw0kqF73Dw 18 | NyoSubwR4qs6FQclKW1TAP6UW4B6ffq1iagKOCTZ5bBtsPBZk8UGCJb57q4fUj4P 19 | nJy0hnSdlOH4Am+US4HF4ayOGuaV1Be1taurdJnt5cNnUYRah0wg4nG+wVdG5HJk 20 | f4dJ4nih9d6WA/8LfxdpB7NCwdR+KK6lky+GgLSdhmIT9lzjj2GDsU4lBf29TkBn 21 | lyt98/LWGrgCQgZAQ/obxLT8CZtY+tNejGoMppY+ub8DIaLBFID+fcz13kgA9x7a 22 | TeVB8RPok+S3yHXP9a4WSFe9DGjjN+m7EnRtte7MEjyMoekXVnT04gNbTMoGAjNb 23 | lrR4g3ICygZtsoGSB2VEu7o3azAspXNBMOuJfRCuC0LDXcjH3TbvjX0da5wHBoK9 24 | clRxu+CDo9A849HMkmSje8wED7ysZnkvSX0OdPjXahVd4t1tDRI6jSlzFo9fGcjp 25 | S8Ikm9iMrHXaWcDdtcq4C63CjSynIBr4mNIxe/f2e9nynm3AIv+aOan891RWHqrd 26 | DdpSSPShtzATI9PbB+b+S0Gw58Y8fpO7yoZ87VW1BMpadmFZ87YY78jdB7BwInNI 27 | JqtnivinM6qCsvbdMoGinUyL6PUcfQGiEAibouKr3zNRDC4aesBZZmj7w0dnf+HK 28 | YC905aR0cddlc6DBo/ed3o9krMcZ6oY/vruemPTc5G7Cg3t4H3mInRgURw22X1wo 29 | FsioU1yOdkK+MYxvmGsQvQuSJhp7h1Uz37t/olkPRafZgy2nEtw6DQO0Dm4UfSsD 30 | nysq6dn1WeZPkOipGBRgQmY1FTRzwPoCxi7+/EuHhD8hr962rHOglSuNqPG89J8r 31 | wdbTDr8kgXj2A9p+jI3TVKEX+h6FEhrCHW9SHUqATOZ7RiNL6hKld9j0U4D9gQwZ 32 | dflA0TxpVsHXm7pd1idkr46jIFgw7HA89Erm0Ty7RolfHkqlRca805AVmsKkviIz 33 | sbF5uv4WzIE3ViO8P1KMUhCyElm72mpyNTXBhkxkup9hJ4fQieaN6pET6dQ2xyjs 34 | SBIvQoXI0JQKpespcyAdoh88ULQjRUXEOaNFfN7q+itTcocwmPZfzW2nXORJT2p8 35 | SXLqSE73nYZdqzSYFq1hLcnlubJ7yPBYYG1fI0IydjSGKfnjtB0DReR32OToRZ7m 36 | laduZ8O+IaBUY4Sp6QdYcVbGGpG/wsPmTQyScc/O2bfSI7AiPnL9EnwebI9sPSWQ 37 | R0t0QMXZOSSqNY6jkYjsOCxeekRIdY6havo2Y52Ywti0QNrkT4BQ+175VVTmRMdy 38 | LNaMFeEq6ehSEdaHaozvjHvP50HQT43tCK+RJiL+Gf9FqawoQRt693yO5LFbQsuw 39 | QsUSMi41txpINMa+HEc2K5FvGoPr7FmajLK7X2fr+3c/yZ4fahoMKEAVFWl5kRYx 40 | Fe1smlw1Vxl/qNQ32LFWsBIK+XnYBteYmlpVyYrTgXyjnp1rK2zz0118DPFuYiAP 41 | O0r6nnBz0NbwnSKb7S4CjxBKDvDbWTzP35Q5L/vySnO2zRbM64Gw7sjeLiJittWS 42 | gQfbFpEk9k8KVndKM4H50Jp0WznmYpm1Tman8hUOiCvmq0qdI3bJ5Bnj0K+q2zFV 43 | +noGpMFdq1+8WaUFLQFGCPM+yJgCqDgT1RAgfsGcomckGcmenDtHaTbcSFabEdpM 44 | Tsa2qLdg/Kju+7JyGrkmobXl/azuyjYTHfRvSZrvO5WUDFzhChrJpIL4nA3ZGRlS 45 | gvy+OzyyBh4sRyHwLItwUwE81aya3W4llAkhQ7OycmqniJgjtJzLwnxv2RQsB8bF 46 | pyoqQdKVxkqHdbUFeh9igI4ffRAK+8xDER5J+RUoZ4mO8qJebxar54XTb6I/Lepc 47 | g8ITX8bJ/GH+M6JdP7tLCikDTSGS+i1ReMQXE5XuEajYOVbzQdyWU5jleZIx0f6X 48 | mTa4WvMEGNyNxKZZXsy9FAaBkZqrNzEv8k0uFgFMNWQcMMtiqbei86yACdqe+jiW 49 | HqHv8wfoBHR+eIARub2itOJ/cI+oKv96d4it4FqQ9Lml8RUFFZj7Hrd6EjDb6Nq4 50 | P9ti7eku/xZvS0saBNChvv44GhP6FZJS0i/gidVffLna7Wua98tPZEAXp57k+XUL 51 | PzsRJ4a+hFuQjkyXFoz/v8YuUdyCFUSVVr9ArVu0v4+4euFWpQLav5sXv0Gh9X58 52 | Ek1KIf7Z/tZAJnSjTjFuSbDX/AoTMTxpRBKKnFW6zY0Nw2pjTVMtTVDkv9xkBpBK 53 | wod7FPD5f0T7y9YOARVZnBxVRSkkcYpEJFy5pLNeadg9 54 | -----END ENCRYPTED PRIVATE KEY----- 55 | -------------------------------------------------------------------------------- /src/Tests/Services/TierPriceFinderTest.php: -------------------------------------------------------------------------------- 1 | */ 31 | private $tierPriceRepo; 32 | 33 | public function setUp(): void 34 | { 35 | $this->tierPriceRepo = $this->createMock(TierPriceRepositoryInterface::class); 36 | $this->tierPriceFinder = new TierPriceFinder($this->tierPriceRepo); 37 | 38 | $this->testChannel = $this->createMock(ChannelInterface::class); 39 | } 40 | 41 | public function testCalculateWithNotEnoughQuantity(): void 42 | { 43 | //## PREPARE 44 | $tierPrice = $this->createMock(TierPrice::class); 45 | $tierPrice->method('getPrice')->willReturn(1); 46 | $tierPrice->method('getQty')->willReturn(20); 47 | 48 | $productVariant = $this->createMock(ProductVariant::class); 49 | $this->tierPriceRepo->method('getSortedTierPrices')->willReturn([$tierPrice]); 50 | 51 | //## EXECUTE 52 | $tierPriceFound = $this->tierPriceFinder->find($productVariant, $this->testChannel, 10); 53 | 54 | //## CHECK 55 | self::assertEquals(null, $tierPriceFound); 56 | } 57 | 58 | public function testCalculateWithOneTierPrice(): void 59 | { 60 | //## PREPARE 61 | $tierPrice = $this->createMock(TierPrice::class); 62 | $tierPrice->method('getPrice')->willReturn(1); 63 | $tierPrice->method('getQty')->willReturn(5); 64 | 65 | $productVariant = $this->createMock(ProductVariant::class); 66 | $this->tierPriceRepo->method('getSortedTierPrices')->willReturn([$tierPrice]); 67 | 68 | //## EXECUTE 69 | $tierPriceFound = $this->tierPriceFinder->find($productVariant, $this->testChannel, 10); 70 | 71 | //## CHECK 72 | self::assertEquals($tierPriceFound, $tierPrice); 73 | } 74 | 75 | public function testCalculateWithHighestTierPrice(): void 76 | { 77 | //## PREPARE 78 | $tierPrice1 = $this->createMock(TierPrice::class); 79 | $tierPrice1->method('getPrice')->willReturn(500); 80 | $tierPrice1->method('getQty')->willReturn(10); 81 | 82 | $tierPrice2 = $this->createMock(TierPrice::class); 83 | $tierPrice2->method('getPrice')->willReturn(10); 84 | $tierPrice2->method('getQty')->willReturn(50); 85 | 86 | $productVariant = $this->createMock(ProductVariant::class); 87 | $this->tierPriceRepo->method('getSortedTierPrices')->willReturn([$tierPrice1, $tierPrice2]); 88 | 89 | //## EXECUTE 90 | $tierPriceFound = $this->tierPriceFinder->find($productVariant, $this->testChannel, 11); 91 | 92 | //## CHECK 93 | self::assertEquals($tierPrice2, $tierPriceFound); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Tests/Services/ProductVariantPriceCalculatorTest.php: -------------------------------------------------------------------------------- 1 | basePriceCalculator = $this->createMock(ProductVariantPricesCalculatorInterface::class); 44 | $this->basePriceCalculator->method('calculate')->willReturn(-1); // To indicate no tier prices 45 | 46 | $this->tierPriceFinder = $this->createMock(TierPriceFinderInterface::class); 47 | 48 | $this->customerContext = $this->createMock(CustomerContextInterface::class); 49 | 50 | $this->priceCalculator = new ProductVariantPriceCalculator($this->basePriceCalculator, $this->tierPriceFinder, $this->customerContext); 51 | } 52 | 53 | public function testCalculateWithNonTierpriceable(): void 54 | { 55 | //## PREPARE 56 | $productVariant = $this->createMock(SyliusProductVariant::class); 57 | $testChannel = $this->createMock(ChannelInterface::class); 58 | 59 | //## EXECUTE 60 | $price = $this->priceCalculator->calculate($productVariant, ['channel' => $testChannel, 'quantity' => 10]); 61 | 62 | //## CHECK 63 | self::assertEquals(-1, $price); 64 | } 65 | 66 | public function testCalculatePriceWithEmptyTierPrices(): void 67 | { 68 | //## PREPARE 69 | $productVariant = $this->createMock(ProductVariant::class); 70 | $testChannel = $this->createMock(ChannelInterface::class); 71 | 72 | $this->tierPriceFinder->method('find')->willReturn(null); 73 | 74 | //## EXECUTE 75 | $result = $this->priceCalculator->calculate($productVariant, ['channel' => $testChannel, 'quantity' => 10]); 76 | 77 | //## CHECK 78 | self::assertEquals(-1, $result); 79 | } 80 | 81 | public function testCalculatePriceWithTierPrices(): void 82 | { 83 | //## PREPARE 84 | $productVariant = $this->createMock(ProductVariant::class); 85 | $testChannel = $this->createMock(ChannelInterface::class); 86 | 87 | $this->tierPriceFinder->method('find')->willReturn(new TierPrice(2, 2)); 88 | 89 | //## EXECUTE 90 | $result = $this->priceCalculator->calculate($productVariant, ['channel' => $testChannel, 'quantity' => 10]); 91 | 92 | //## CHECK 93 | self::assertEquals(2, $result); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Tests/Services/OrderPricesRecalculatorTest.php: -------------------------------------------------------------------------------- 1 | createMock(ProductVariantPricesCalculatorInterface::class); 39 | $calculated = &$this->calculated; 40 | 41 | $productVariantCalculator->method('calculate')->willReturnCallback( 42 | function (ProductVariantInterface $productVariant, array $options) use (&$calculated): int { 43 | Assert::keyExists($options, 'quantity'); 44 | Assert::keyExists($options, 'channel'); 45 | $calculated[] = $options['quantity'] * 2; 46 | 47 | return 0; 48 | }, 49 | ); 50 | 51 | $orderProcessor = $this->createMock(OrderProcessorInterface::class); 52 | 53 | $this->orderPriceRecalculator = new OrderPricesRecalculator($productVariantCalculator, $orderProcessor); 54 | } 55 | 56 | /** @dataProvider dataProcessOrder */ 57 | public function testProcessOrder(array $orderItems, array $expectedUnitPrices): void 58 | { 59 | //## PREPARE 60 | $channel = $this->createMock(ChannelInterface::class); 61 | 62 | $order = $this->createMock(OrderInterface::class); 63 | $order->method('getChannel')->willReturn($channel); 64 | $order->method('getItems')->willReturn(new ArrayCollection($orderItems)); 65 | 66 | //## EXECUTE 67 | $this->orderPriceRecalculator->process($order); 68 | 69 | //## CHECK 70 | foreach ($expectedUnitPrices as $index => $expectedUnitPrice) { 71 | self::assertEquals($expectedUnitPrice, $this->calculated[$index]); 72 | } 73 | } 74 | 75 | public function dataProcessOrder(): array 76 | { 77 | return [ 78 | 'one product' => [ 79 | [$this->createOrder(2)], 80 | [4], 81 | ], 82 | 'multiple products' => [ 83 | [$this->createOrder(1), $this->createOrder(5)], 84 | [2, 10], 85 | ], 86 | ]; 87 | } 88 | 89 | private function createOrder(int $quantity): OrderItemInterface 90 | { 91 | $orderItem = $this->createMock(OrderItem::class); 92 | $productVariant = $this->createMock(ProductVariantInterface::class); 93 | 94 | $orderItem->method('getVariant')->willReturn($productVariant); 95 | $orderItem->method('getQuantity')->willReturn($quantity); 96 | 97 | return $orderItem; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | 3 | # Sylius Tier Price Plugin 4 | [![Build Status](https://travis-ci.org/Brille24/SyliusTierpricePlugin.svg?branch=master)](https://travis-ci.org/Brille24/SyliusTierpricePlugin) 5 | 6 | This plugin adds tier pricing to Sylius one product has different prices based on the quantity. 7 | 8 | ## Installation 9 | * Install the bundle via composer `composer require brille24/sylius-tierprice-plugin` 10 | * Register the bundle in your `bundles.php`: 11 | ```php 12 | return [ 13 | //... 14 | 15 | Brille24\SyliusTierPricePlugin\Brille24SyliusTierPricePlugin::class => ['all' => true], 16 | ]; 17 | ``` 18 | 19 | * Add the `config.yaml` to your local `config/config.yaml` 20 | ```yml 21 | imports: 22 | ... 23 | - { resource: '@Brille24SyliusTierPricePlugin/config/config.yaml'} 24 | ``` 25 | 26 | * For API functionality add the bundle's `routes.yml` to the local `app/config/routes.yml` 27 | ```yml 28 | ... 29 | brille24_tierprice_bundle: 30 | resource: '@Brille24SyliusTierPricePlugin/config/routes.yml' 31 | ``` 32 | 33 | * Go into your `ProductVariant` class and add the following trait and add one method call to the constructor 34 | ```php 35 | class ProductVariant extends BaseProductVariant implements \Brille24\SyliusTierPricePlugin\Entity\ProductVariantInterface 36 | { 37 | use \Brille24\SyliusTierPricePlugin\Traits\TierPriceableTrait; 38 | 39 | public function __construct() { 40 | parent::__construct(); // Your constructor here 41 | 42 | $this->initTierPriceableTrait(); // "Constructor" of the trait 43 | } 44 | 45 | protected function createTranslation(): ProductVariantTranslationInterface 46 | { 47 | return new ProductVariantTranslation(); 48 | } 49 | } 50 | ```` 51 | 52 | * Finally update the database, install the assets and update the translations: 53 | ```sh 54 | bin/console doctrine:schema:update --force 55 | bin/console assets:install 56 | bin/console translation:update --force 57 | ``` 58 | 59 | ### Integration 60 | * This bundle decorates the `sylius.calculator.product_variant_price` service. If you wish to change that, you could register a [compiler pass](https://symfony.com/doc/current/service_container/compiler_passes.html). 61 | * This bundle decorates the `sylius.order_processing.order_prices_recalculator` service. If you wish to use your own order processor or change its priority, you could register a [compiler pass](https://symfony.com/doc/current/service_container/compiler_passes.html). 62 | 63 | ## Usage 64 | First of all you have to set up a product with as many variants as you want. Then in each of these variants you can set the tier pricing based on the channels. 65 | The table automatically sorts itself to provide a better overview for all different tiers, you configured. 66 | 67 | 68 | 69 | In the frontend the user will see a nice looking table right next to the "add to cart" button that shows the discount for the different tiers like so: 70 | 71 | 72 | 73 | ### Creating data 74 | You can easily create the tier prices with fixtures like that. 75 | ```yaml 76 | sylius_fixtures: 77 | suites: 78 | my_suite: 79 | fixtures: 80 | tier_prices: 81 | options: 82 | custom: 83 | - product_variant: "20125148-54ca-3f05-875f-5524f95aa85b" 84 | channel: US_WEB 85 | quantity: 10 86 | price: 5 87 | ``` 88 | For this the products need to be created first and the product variant must also exist. 89 | 90 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | services(); 37 | $services->defaults()->autoconfigure()->autowire(); 38 | 39 | $services->alias(TierPriceRepositoryInterface::class, 'brille24.repository.tierprice'); 40 | 41 | $services->set(TierPriceFinderInterface::class, TierPriceFinder::class); 42 | 43 | $services->set(ProductVariantPricesCalculatorInterface::class, ProductVariantPriceCalculator::class) 44 | ->decorate('sylius.calculator.product_variant_price') 45 | ->args([ 46 | service('.inner'), 47 | ]) 48 | ; 49 | 50 | $services->set(OrderPricesRecalculator::class) 51 | ->decorate('sylius.order_processing.order_prices_recalculator') 52 | ->arg('$orderProcessor', service('.inner')) 53 | ; 54 | 55 | $services->set(TierPriceFactoryInterface::class, TierPriceFactory::class) 56 | ->decorate('brille24.factory.tierprice') 57 | ->args([service('.inner')]) 58 | ; 59 | 60 | $services->set('sylius_admin.twig.component.product_variant.form', ProductVariantFormComponent::class) 61 | ->args([ 62 | service('sylius.repository.product_variant'), 63 | service('form.factory'), 64 | '%sylius.model.product_variant.class%', 65 | 'Sylius\Bundle\AdminBundle\Form\Type\ProductVariantType', 66 | service('sylius.factory.product_variant'), 67 | service('sylius.repository.product'), 68 | ]) 69 | ->tag('sylius.live_component.admin', ['key' => "sylius_admin:product_variant:form" ]) 70 | ; 71 | 72 | $services->set(TierPriceExampleFactory::class); 73 | 74 | $services->set(TierPriceFixture::class) 75 | ->args([ 76 | service('doctrine.orm.default_entity_manager'), 77 | service(TierPriceExampleFactory::class), 78 | ]) 79 | ->tag('sylius_fixtures.fixture') 80 | ; 81 | 82 | $services->set(TierPriceType::class); 83 | 84 | $services->set(ProductVariantTypeExtension::class) 85 | ->tag('form.type_extension', ['extended_type' => ProductVariantType::class, 'priority' => -5]) 86 | ; 87 | 88 | $services->set(TierPriceUniqueValidator::class) 89 | ->args([service('doctrine')]) 90 | ->tag('validator.constraint_validator', ['alias' => 'brille24.tier_price_validator.unqiue']) 91 | ; 92 | }; 93 | -------------------------------------------------------------------------------- /src/Tests/Entity/ProductVariantTest.php: -------------------------------------------------------------------------------- 1 | setChannel($channel); 28 | $result->setQty($quantity); 29 | 30 | return $result; 31 | } 32 | 33 | /** @dataProvider data_getTierPricesForChannel 34 | * 35 | */ 36 | public function test_getTierPricesForChannel( 37 | array $givenTierPrices, 38 | array $expectedTierPrices, 39 | array $channels, 40 | ): void { 41 | //## PREPARE 42 | $productVariant = new ProductVariant(); 43 | $productVariant->setTierPrices($givenTierPrices); 44 | 45 | //## EXECUTE 46 | $resultEntries = $productVariant->getTierPricesForChannel($channels['testChannel']); 47 | 48 | //## CHECK 49 | self::assertCount(count($resultEntries), $expectedTierPrices); 50 | $i = 0; 51 | foreach ($resultEntries as $entry) { 52 | /** @var TierPrice $entry */ 53 | self::assertEquals($expectedTierPrices[$i]->getQty(), $entry->getQty()); 54 | ++$i; 55 | } 56 | } 57 | 58 | public function data_getTierPricesForChannel(): array 59 | { 60 | // We can't put this in setUp() as data providers are called before setUp(). 61 | $testChannel = $this->createMock(ChannelInterface::class); 62 | $testChannel->method('getId')->willReturn(1); 63 | 64 | $otherChannel = $this->createMock(ChannelInterface::class); 65 | $otherChannel->method('getId')->willReturn(2); 66 | 67 | $channels = [ 68 | 'testChannel' => $testChannel, 69 | 'otherChannel' => $otherChannel, 70 | ]; 71 | 72 | return 73 | [ 74 | 'no tier prices' => [ 75 | [], 76 | [], 77 | $channels, 78 | ], 79 | 'one tier price matches' => [ 80 | // Input 81 | [$this->createTierPrice($testChannel, 1)], 82 | // Expected Output 83 | [$this->createTierPrice($testChannel, 1)], 84 | $channels, 85 | ], 86 | 'one tier price no match' => [ 87 | // Input 88 | [$this->createTierPrice($otherChannel, 10)], 89 | // Expected Output 90 | [], 91 | $channels, 92 | ], 93 | 'multiple tier prices' => [ 94 | // Input 95 | [ 96 | $this->createTierPrice($otherChannel, 1), 97 | $this->createTierPrice($otherChannel, 2), 98 | $this->createTierPrice($testChannel, 3), 99 | $this->createTierPrice($otherChannel, 4), 100 | $this->createTierPrice($testChannel, 5), 101 | ], 102 | // Expected Output 103 | [ 104 | $this->createTierPrice($testChannel, 3), 105 | $this->createTierPrice($testChannel, 5), 106 | ], 107 | $channels, 108 | ], 109 | ]; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Application/config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 6 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 7 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 8 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 9 | Sylius\Bundle\OrderBundle\SyliusOrderBundle::class => ['all' => true], 10 | Sylius\Bundle\MoneyBundle\SyliusMoneyBundle::class => ['all' => true], 11 | Sylius\Bundle\CurrencyBundle\SyliusCurrencyBundle::class => ['all' => true], 12 | Sylius\Bundle\LocaleBundle\SyliusLocaleBundle::class => ['all' => true], 13 | Sylius\Bundle\ProductBundle\SyliusProductBundle::class => ['all' => true], 14 | Sylius\Bundle\ChannelBundle\SyliusChannelBundle::class => ['all' => true], 15 | Sylius\Bundle\AttributeBundle\SyliusAttributeBundle::class => ['all' => true], 16 | Sylius\Bundle\TaxationBundle\SyliusTaxationBundle::class => ['all' => true], 17 | Sylius\Bundle\ShippingBundle\SyliusShippingBundle::class => ['all' => true], 18 | Sylius\Bundle\PaymentBundle\SyliusPaymentBundle::class => ['all' => true], 19 | Sylius\Bundle\MailerBundle\SyliusMailerBundle::class => ['all' => true], 20 | Sylius\Bundle\PromotionBundle\SyliusPromotionBundle::class => ['all' => true], 21 | Sylius\Bundle\AddressingBundle\SyliusAddressingBundle::class => ['all' => true], 22 | Sylius\Bundle\InventoryBundle\SyliusInventoryBundle::class => ['all' => true], 23 | Sylius\Bundle\TaxonomyBundle\SyliusTaxonomyBundle::class => ['all' => true], 24 | Sylius\Bundle\UserBundle\SyliusUserBundle::class => ['all' => true], 25 | Sylius\Bundle\CustomerBundle\SyliusCustomerBundle::class => ['all' => true], 26 | Sylius\Bundle\UiBundle\SyliusUiBundle::class => ['all' => true], 27 | Sylius\Bundle\ReviewBundle\SyliusReviewBundle::class => ['all' => true], 28 | Sylius\Bundle\CoreBundle\SyliusCoreBundle::class => ['all' => true], 29 | Sylius\Bundle\ResourceBundle\SyliusResourceBundle::class => ['all' => true], 30 | Sylius\Bundle\GridBundle\SyliusGridBundle::class => ['all' => true], 31 | Knp\Bundle\GaufretteBundle\KnpGaufretteBundle::class => ['all' => true], 32 | Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], 33 | Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true], 34 | Payum\Bundle\PayumBundle\PayumBundle::class => ['all' => true], 35 | Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], 36 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 37 | Sylius\Bundle\FixturesBundle\SyliusFixturesBundle::class => ['all' => true], 38 | Sylius\Bundle\PayumBundle\SyliusPayumBundle::class => ['all' => true], 39 | Sylius\Bundle\ThemeBundle\SyliusThemeBundle::class => ['all' => true], 40 | Sylius\Bundle\AdminBundle\SyliusAdminBundle::class => ['all' => true], 41 | Sylius\Bundle\ShopBundle\SyliusShopBundle::class => ['all' => true], 42 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], 43 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'test_cached' => true], 44 | FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['test' => true, 'test_cached' => true], 45 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 46 | Sylius\Bundle\ApiBundle\SyliusApiBundle::class => ['all' => true], 47 | Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], 48 | SyliusLabs\DoctrineMigrationsExtraBundle\SyliusLabsDoctrineMigrationsExtraBundle::class => ['all' => true], 49 | BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], 50 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 51 | League\FlysystemBundle\FlysystemBundle::class => ['all' => true], 52 | Sylius\TwigExtra\Symfony\SyliusTwigExtraBundle::class => ['all' => true], 53 | Sylius\TwigHooks\SyliusTwigHooksBundle::class => ['all' => true], 54 | Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], 55 | Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], 56 | Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], 57 | Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true], 58 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 59 | Sylius\Abstraction\StateMachine\SyliusStateMachineAbstractionBundle::class => ['all' => true], 60 | Brille24\SyliusTierPricePlugin\Brille24SyliusTierPricePlugin::class => ['all' => true] 61 | ]; 62 | 63 | if (class_exists(winzou\Bundle\StateMachineBundle\winzouStateMachineBundle::class)) { 64 | $bundles[winzou\Bundle\StateMachineBundle\winzouStateMachineBundle::class] = ['all' => true]; 65 | } 66 | 67 | return $bundles; 68 | -------------------------------------------------------------------------------- /src/Validator/TierPriceUniqueValidator.php: -------------------------------------------------------------------------------- 1 | fields; 44 | if (0 === count($fields)) { 45 | throw new ConstraintDefinitionException('At least one field has to be specified.'); 46 | } 47 | 48 | $em = $this->registry->getManagerForClass($value::class); 49 | if ($em === null) { 50 | throw new ConstraintDefinitionException( 51 | sprintf( 52 | 'Unable to find the object manager associated with an entity of class "%s".', 53 | $value::class, 54 | ), 55 | ); 56 | } 57 | 58 | /** @psalm-suppress MixedMethodCall */ 59 | $formData = $this->context->getRoot()->getData(); 60 | if ($formData instanceof ProductInterface && $formData->getVariants()->count() === 1) { 61 | $formData = $formData->getVariants()->first(); 62 | } 63 | if (!$formData instanceof ProductVariantInterface) { 64 | throw new ConstraintDefinitionException('Unable to find ProductVariant in form.'); 65 | } 66 | $otherTierPrices = $formData->getTierPrices(); 67 | 68 | $otherTierPrices = array_filter($otherTierPrices, static fn ($tierPrice): bool => $tierPrice !== $value); 69 | 70 | foreach ($otherTierPrices as $otherTierPrice) { 71 | if ($this->areDuplicates($fields, $em, $value, $otherTierPrice)) { 72 | $this->context->buildViolation($constraint->message)->atPath($fields[0])->addViolation(); 73 | 74 | return; 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * @param string[] $fields 81 | */ 82 | private function areDuplicates(array $fields, ObjectManager $em, TierPriceInterface $first, TierPriceInterface $second): bool 83 | { 84 | /** @var ClassMetadataInfo $class */ 85 | $class = $em->getClassMetadata($first::class); 86 | Assert::isInstanceOf($class, ClassMetadataInfo::class); 87 | 88 | foreach ($fields as $fieldName) { 89 | if (!$class->hasField($fieldName) && !$class->hasAssociation($fieldName)) { 90 | throw new ConstraintDefinitionException( 91 | sprintf( 92 | 'The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', 93 | $fieldName, 94 | ), 95 | ); 96 | } 97 | /** @psalm-suppress MixedAssignment $fieldValue */ 98 | $fieldValue = $this->getFieldValue($em, $class, $fieldName, $first); 99 | /** @psalm-suppress MixedAssignment $fieldValue */ 100 | $otherFieldValue = $this->getFieldValue($em, $class, $fieldName, $second); 101 | if ($fieldValue !== $otherFieldValue) { 102 | return false; 103 | } 104 | } 105 | 106 | return true; 107 | } 108 | 109 | /** 110 | * @return mixed 111 | */ 112 | private function getFieldValue(ObjectManager $em, ClassMetadataInfo $class, string $fieldName, TierPriceInterface $value) 113 | { 114 | /** @var ReflectionProperty $fieldMetaData */ 115 | $fieldMetaData = $class->reflFields[$fieldName]; 116 | 117 | /** @psalm-suppress MixedAssignment $fieldValue */ 118 | $fieldValue = $fieldMetaData->getValue($value); 119 | 120 | if (null !== $fieldValue && $class->hasAssociation($fieldName)) { 121 | /* Ensure the Proxy is initialized before using reflection to 122 | * read its identifiers. This is necessary because the wrapped 123 | * getter methods in the Proxy are being bypassed. 124 | */ 125 | $em->initializeObject($fieldValue); 126 | } 127 | 128 | return $fieldValue; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Traits/TierPriceableTrait.php: -------------------------------------------------------------------------------- 1 | @see ProductVariant 33 | */ 34 | trait TierPriceableTrait 35 | { 36 | public function initTierPriceableTrait(): void 37 | { 38 | $this->tierPrices = new ArrayCollection(); 39 | } 40 | 41 | /** @var ArrayCollection */ 42 | #[OneToMany(mappedBy: 'productVariant', targetEntity: TierPrice::class, cascade: ['all'], orphanRemoval: true)] 43 | #[OrderBy(['customerGroup' => 'ASC', 'qty' => 'ASC'])] 44 | protected $tierPrices; 45 | 46 | /** 47 | * Returns all tier prices for this product variant. 48 | * 49 | * @return TierPriceInterface[] 50 | */ 51 | public function getTierPrices(): array 52 | { 53 | return $this->tierPrices->toArray(); 54 | } 55 | 56 | /** 57 | * Returns the tier prices only for one channel 58 | * 59 | * @return TierPriceInterface[] 60 | */ 61 | public function getTierPricesForChannel(ChannelInterface $channel, ?CustomerInterface $customer = null): array 62 | { 63 | $channelTierPrices = array_filter($this->getTierPrices(), function (TierPriceInterface $tierPrice) use ($channel): bool { 64 | $tierPriceChannel = $tierPrice->getChannel(); 65 | 66 | return $tierPriceChannel !== null && $tierPriceChannel->getId() === $channel->getId(); 67 | }); 68 | 69 | return $this->filterPricesWithCustomerGroup($channelTierPrices, $customer); 70 | } 71 | 72 | /** 73 | * Returns the tier prices only for one channel 74 | * 75 | * @return TierPriceInterface[] 76 | */ 77 | public function getTierPricesForChannelCode(string $code, ?CustomerInterface $customer = null): array 78 | { 79 | $channelTierPrices = array_filter($this->getTierPrices(), function (TierPriceInterface $tierPrice) use ($code): bool { 80 | $tierPriceChannel = $tierPrice->getChannel(); 81 | 82 | return $tierPriceChannel !== null && $tierPriceChannel->getCode() === $code; 83 | }); 84 | 85 | return $this->filterPricesWithCustomerGroup($channelTierPrices, $customer); 86 | } 87 | 88 | /** 89 | * Removes a tier price from the array collection 90 | */ 91 | public function removeTierPrice(TierPriceInterface $tierPrice): void 92 | { 93 | $this->tierPrices->removeElement($tierPrice); 94 | } 95 | 96 | /** 97 | * Adds an element to the list 98 | */ 99 | public function addTierPrice(TierPriceInterface $tierPrice): void 100 | { 101 | $tierPrice->setProductVariant($this); 102 | $this->tierPrices->add($tierPrice); 103 | } 104 | 105 | /** 106 | * Sets the tier prices form the array collection 107 | * 108 | * @param TierPriceInterface[] $tierPrices 109 | */ 110 | public function setTierPrices(array $tierPrices): void 111 | { 112 | if (!$this instanceof ProductVariantInterface) { 113 | return; 114 | } 115 | 116 | $this->tierPrices = new ArrayCollection(); 117 | 118 | foreach ($tierPrices as $tierPrice) { 119 | $this->addTierPrice($tierPrice); 120 | } 121 | } 122 | 123 | /** 124 | * @param TierPriceInterface[] $tierPrices 125 | * 126 | * @return TierPriceInterface[] 127 | */ 128 | private function filterPricesWithCustomerGroup(array $tierPrices, ?CustomerInterface $customer = null): array 129 | { 130 | $group = null; 131 | if ($customer instanceof CustomerInterface) { 132 | $group = $customer->getGroup(); 133 | } 134 | 135 | // Check if there are any tier prices specifically for the passed customer's group 136 | $hasGroupPrice = false; 137 | if ($group instanceof CustomerGroupInterface) { 138 | foreach ($tierPrices as $tierPrice) { 139 | /** @psalm-suppress PossiblyNullReference */ 140 | if ( 141 | $tierPrice->getCustomerGroup() instanceof CustomerGroupInterface && 142 | $tierPrice->getCustomerGroup()->getId() === $group->getId() 143 | ) { 144 | $hasGroupPrice = true; 145 | 146 | break; 147 | } 148 | } 149 | } 150 | 151 | if (!$group instanceof CustomerGroupInterface || !$hasGroupPrice) { 152 | /* 153 | * We either have no CustomerGroup or there are no tier prices for the specified group so only return 154 | * tier prices with no customer group set 155 | */ 156 | return array_filter($tierPrices, static fn (TierPriceInterface $tierPrice): bool => $tierPrice->getCustomerGroup() === null); 157 | } 158 | 159 | /* 160 | * We have a customer group and $tierPrices contains tier prices for that specific group so only return 161 | * tier prices for that group 162 | */ 163 | return array_filter($tierPrices, static fn (TierPriceInterface $tierPrice): bool => /** @psalm-suppress PossiblyNullReference */ 164 | $tierPrice->getCustomerGroup() !== null && 165 | $tierPrice->getCustomerGroup()->getId() === $group->getId()); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/Application/config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | providers: 3 | sylius_admin_user_provider: 4 | id: sylius.admin_user_provider.email_or_name_based 5 | sylius_api_admin_user_provider: 6 | id: sylius.admin_user_provider.email_or_name_based 7 | sylius_shop_user_provider: 8 | id: sylius.shop_user_provider.email_or_name_based 9 | sylius_api_shop_user_provider: 10 | id: sylius.shop_user_provider.email_or_name_based 11 | 12 | password_hashers: 13 | Sylius\Component\User\Model\UserInterface: argon2i 14 | firewalls: 15 | admin: 16 | switch_user: true 17 | context: admin 18 | pattern: "%sylius.security.admin_regex%" 19 | provider: sylius_admin_user_provider 20 | user_checker: security.user_checker.chain.admin 21 | form_login: 22 | provider: sylius_admin_user_provider 23 | login_path: sylius_admin_login 24 | check_path: sylius_admin_login_check 25 | failure_path: sylius_admin_login 26 | default_target_path: sylius_admin_dashboard 27 | use_forward: false 28 | use_referer: true 29 | enable_csrf: true 30 | csrf_parameter: _csrf_admin_security_token 31 | csrf_token_id: admin_authenticate 32 | remember_me: 33 | secret: "%env(APP_SECRET)%" 34 | path: "/%sylius_admin.path_name%" 35 | name: APP_ADMIN_REMEMBER_ME 36 | lifetime: 31536000 37 | remember_me_parameter: _remember_me 38 | logout: 39 | path: sylius_admin_logout 40 | target: sylius_admin_login 41 | 42 | api_admin: 43 | pattern: "%sylius.security.api_admin_regex%/.*" 44 | provider: sylius_api_admin_user_provider 45 | user_checker: security.user_checker.chain.api_admin 46 | stateless: true 47 | entry_point: jwt 48 | json_login: 49 | check_path: "%sylius.security.api_admin_route%/administrators/token" 50 | username_path: email 51 | password_path: password 52 | success_handler: lexik_jwt_authentication.handler.authentication_success 53 | failure_handler: lexik_jwt_authentication.handler.authentication_failure 54 | jwt: true 55 | 56 | api_shop: 57 | pattern: "%sylius.security.api_shop_regex%/.*" 58 | provider: sylius_api_shop_user_provider 59 | user_checker: security.user_checker.chain.api_shop 60 | stateless: true 61 | entry_point: jwt 62 | json_login: 63 | check_path: "%sylius.security.api_shop_route%/customers/token" 64 | username_path: email 65 | password_path: password 66 | success_handler: lexik_jwt_authentication.handler.authentication_success 67 | failure_handler: lexik_jwt_authentication.handler.authentication_failure 68 | jwt: true 69 | 70 | shop: 71 | switch_user: { role: ROLE_ALLOWED_TO_SWITCH } 72 | context: shop 73 | pattern: "%sylius.security.shop_regex%" 74 | provider: sylius_shop_user_provider 75 | user_checker: security.user_checker.chain.shop 76 | form_login: 77 | success_handler: sylius.authentication.success_handler 78 | failure_handler: sylius.authentication.failure_handler 79 | provider: sylius_shop_user_provider 80 | login_path: sylius_shop_login 81 | check_path: sylius_shop_login_check 82 | failure_path: sylius_shop_login 83 | default_target_path: sylius_shop_homepage 84 | use_forward: false 85 | use_referer: true 86 | enable_csrf: true 87 | csrf_parameter: _csrf_shop_security_token 88 | csrf_token_id: shop_authenticate 89 | json_login: 90 | check_path: sylius_shop_json_login_check 91 | username_path: _username 92 | password_path: _password 93 | success_handler: sylius.authentication.success_handler 94 | failure_handler: sylius.authentication.failure_handler 95 | remember_me: 96 | secret: "%env(APP_SECRET)%" 97 | name: APP_SHOP_REMEMBER_ME 98 | lifetime: 31536000 99 | remember_me_parameter: _remember_me 100 | logout: 101 | path: sylius_shop_logout 102 | target: sylius_shop_homepage 103 | invalidate_session: false 104 | 105 | image_resolver: 106 | pattern: ^/media/cache/resolve 107 | security: false 108 | 109 | dev: 110 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 111 | security: false 112 | 113 | access_control: 114 | - { path: "%sylius.security.admin_regex%/forgotten-password", role: PUBLIC_ACCESS } 115 | 116 | - { path: "%sylius.security.admin_regex%/login", role: PUBLIC_ACCESS } 117 | - { path: "%sylius.security.shop_regex%/login", role: PUBLIC_ACCESS } 118 | 119 | - { path: "%sylius.security.shop_regex%/register", role: PUBLIC_ACCESS } 120 | - { path: "%sylius.security.shop_regex%/verify", role: PUBLIC_ACCESS } 121 | 122 | - { path: "%sylius.security.admin_regex%", role: ROLE_ADMINISTRATION_ACCESS } 123 | - { path: "%sylius.security.shop_regex%/account", role: ROLE_USER } 124 | 125 | - { path: "%sylius.security.api_admin_route%/administrators/reset-password", role: PUBLIC_ACCESS } 126 | - { path: "%sylius.security.api_admin_regex%/.*", role: ROLE_API_ACCESS } 127 | - { path: "%sylius.security.api_admin_route%/administrators/token", role: PUBLIC_ACCESS } 128 | - { path: "%sylius.security.api_shop_account_regex%/.*", role: ROLE_USER } 129 | - { path: "%sylius.security.api_shop_route%/customers/token", role: PUBLIC_ACCESS } 130 | - { path: "%sylius.security.api_shop_regex%/.*", role: PUBLIC_ACCESS } 131 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | pull_request: ~ 8 | release: 9 | types: [created] 10 | schedule: 11 | - 12 | cron: "0 1 * * 6" # Run at 1am every Saturday 13 | workflow_dispatch: ~ 14 | 15 | jobs: 16 | tests: 17 | runs-on: ubuntu-latest 18 | 19 | name: "Sylius ${{ matrix.sylius }}, PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, MySQL ${{ matrix.mysql }}" 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | php: ["8.2", "8.3"] 25 | symfony: ["^6.4", "^7.1"] 26 | node: ["20.x"] 27 | mysql: ["8.0"] 28 | 29 | env: 30 | APP_ENV: test 31 | DATABASE_URL: "mysql://root:root@127.0.0.1/sylius?serverVersion=${{ matrix.mysql }}" 32 | 33 | steps: 34 | - 35 | uses: actions/checkout@v4 36 | 37 | - 38 | name: Setup PHP 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: "${{ matrix.php }}" 42 | extensions: intl 43 | tools: flex,symfony 44 | coverage: none 45 | 46 | - 47 | name: Setup Node 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: "${{ matrix.node }}" 51 | 52 | - 53 | name: Shutdown default MySQL 54 | run: sudo service mysql stop 55 | 56 | - 57 | name: Setup MySQL 58 | uses: mirromutth/mysql-action@v1.1 59 | with: 60 | mysql version: "${{ matrix.mysql }}" 61 | mysql root password: "root" 62 | 63 | - 64 | name: Output PHP version for Symfony CLI 65 | run: php -v | head -n 1 | awk '{ print $2 }' > .php-version 66 | 67 | - 68 | name: Install certificates 69 | run: symfony server:ca:install 70 | 71 | - 72 | name: Run Chrome Headless 73 | run: google-chrome-stable --enable-automation --disable-background-networking --no-default-browser-check --no-first-run --disable-popup-blocking --disable-default-apps --allow-insecure-localhost --disable-translate --disable-extensions --no-sandbox --enable-features=Metal --headless --remote-debugging-port=9222 --window-size=2880,1800 --proxy-server='direct://' --proxy-bypass-list='*' http://127.0.0.1 > /dev/null 2>&1 & 74 | 75 | - 76 | name: Run webserver 77 | run: (cd tests/Application && symfony server:start --port=8080 --dir=public --daemon) 78 | 79 | - 80 | name: Get Composer cache directory 81 | id: composer-cache 82 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 83 | 84 | - 85 | name: Cache Composer 86 | uses: actions/cache@v4 87 | with: 88 | path: ${{ steps.composer-cache.outputs.dir }} 89 | key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json **/composer.lock') }} 90 | restore-keys: | 91 | ${{ runner.os }}-php-${{ matrix.php }}-composer- 92 | 93 | - 94 | name: Configure global composer 95 | run: | 96 | composer global config --no-plugins allow-plugins.symfony/flex true 97 | composer global require --no-progress --no-scripts --no-plugins "symfony/flex:^2.2.2" 98 | 99 | - 100 | name: Restrict Symfony version 101 | if: matrix.symfony != '' 102 | run: | 103 | composer config extra.symfony.require "${{ matrix.symfony }}" 104 | 105 | - 106 | name: Restrict Sylius version 107 | if: matrix.sylius != '' 108 | run: composer require "sylius/sylius:${{ matrix.sylius }}" --no-update --no-scripts --no-interaction 109 | 110 | - 111 | name: Install PHP dependencies 112 | run: composer install --no-interaction 113 | env: 114 | SYMFONY_REQUIRE: ${{ matrix.symfony }} 115 | 116 | - 117 | name: Get Yarn cache directory 118 | id: yarn-cache 119 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 120 | 121 | - 122 | name: Cache Yarn 123 | uses: actions/cache@v4 124 | with: 125 | path: ${{ steps.yarn-cache.outputs.dir }} 126 | key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/package.json **/yarn.lock') }} 127 | restore-keys: | 128 | ${{ runner.os }}-node-${{ matrix.node }}-yarn- 129 | 130 | - 131 | name: Install JS dependencies 132 | run: (cd tests/Application && yarn install) 133 | 134 | - 135 | name: Prepare test application database 136 | run: | 137 | (cd tests/Application && bin/console doctrine:database:create -vvv) 138 | (cd tests/Application && bin/console doctrine:schema:create -vvv) 139 | 140 | - 141 | name: Prepare test application assets 142 | run: | 143 | (cd tests/Application && bin/console assets:install public -vvv) 144 | (cd tests/Application && yarn build:prod) 145 | 146 | - 147 | name: Prepare test application cache 148 | run: (cd tests/Application && bin/console cache:warmup -vvv) 149 | 150 | - 151 | name: Load fixtures in test application 152 | run: (cd tests/Application && bin/console sylius:fixtures:load -n) 153 | 154 | - 155 | name: Validate composer.json 156 | run: composer validate --ansi --strict 157 | 158 | - 159 | name: Validate database schema 160 | run: (cd tests/Application && bin/console doctrine:schema:validate) 161 | 162 | - 163 | name: Run PHPStan 164 | run: vendor/bin/phpstan analyse -c phpstan.neon -l max src/ 165 | 166 | - 167 | name: Run PHPSpec 168 | run: vendor/bin/phpspec run --ansi -f progress --no-interaction 169 | 170 | - 171 | name: Run PHPUnit 172 | run: vendor/bin/phpunit --colors=always 173 | 174 | - 175 | name: Run Behat 176 | run: vendor/bin/behat --colors --strict -vvv --no-interaction -f progress || vendor/bin/behat --colors --strict -vvv --no-interaction -f progress --rerun 177 | 178 | - 179 | name: Upload Behat logs 180 | uses: actions/upload-artifact@v4 181 | if: failure() 182 | with: 183 | name: Behat logs 184 | path: etc/build/ 185 | if-no-files-found: ignore 186 | --------------------------------------------------------------------------------