├── .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 |
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 | | {{ 'sylius.ui.quantity'|trans }} |
15 | {{ 'sylius.ui.unit_price'|trans }} |
16 |
17 |
18 |
19 |
20 | | {{ 1|number_format }} |
21 | {{ money.convertAndFormat(product_variant.channelPricings[sylius.channel.code].price) }} |
22 |
23 | {% for tierPrice in tier_prices %}
24 |
25 | | {{ tierPrice.qty|number_format }} |
26 | {{ money.convertAndFormat(tierPrice.price, sylius.channel.baseCurrency) }} |
27 |
28 | {% endfor %}
29 |
30 |
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 |
7 |
8 |
9 |
10 |
11 | | {{ 'sylius.ui.quantity'|trans }} |
12 | {{ 'sylius.ui.unit_price'|trans }} |
13 | {{ 'brille24_tier_price.ui.customer_group'|trans }} |
14 | {{ 'sylius.ui.delete'|trans }} |
15 |
16 |
17 |
18 | {# Rendering the table body #}
19 | {% for tierprice in form.tierPrices %}
20 | {% if tierprice.channel.vars['value'] == channel.code %}
21 |
22 | |
23 |
24 | {{ form_widget(tierprice.qty) }}
25 | {{ form_errors(tierprice.qty) }}
26 |
27 | |
28 |
29 | {{ form_widget(tierprice.channel) }}
30 |
31 | {{ form_widget(tierprice.price, {currency: channel.baseCurrency.code|default('USD')}) }}
32 | {{ form_errors(tierprice.price) }}
33 |
34 | |
35 |
36 |
37 | {{ form_widget(tierprice.customerGroup, {'attr': {'class': 'ui dropdown'}}) }}
38 | {{ form_errors(tierprice.customerGroup) }}
39 |
40 | |
41 |
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 | |
47 |
48 | {% endif %}
49 | {% endfor %}
50 |
51 |
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 | [](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 |
--------------------------------------------------------------------------------